You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This commit was created on GitHub.com and signed with GitHub’s verified signature.
[0.1.8] - 2026-05-15
Breaking changes
Validation / judge / hallucination subsystem deleted entirely (~7,200 lines). Authors no longer set validate: on actions; the runtime no longer injects record_validation alongside complete. The empty-output failure mode where an LLM-as-agent action ended on record_validation and never emitted text — silently surfacing summary: "" and artifactIds: [] to the chat-tool wrapper — is gone by construction. Pre-existing workspaces with validate: in their YAML load cleanly (Zod silently drops the field). Deleted: packages/hallucination/, packages/system/agents/judge-agent/ (~660 lines), packages/system/skills/validating-llm-outputs/SKILL.md, packages/core/src/agent-context/{record-validation-tool,validate-decision}.ts, packages/config/src/validation.ts, packages/fsm-engine/validate-classifier.ts, packages/mcp-server/src/tools/platform/record-validation.ts, the runJudge / workspaceValidation / jobValidation plumbing through workspace runtime → orchestrator → from-llm (~880 lines from fsm-engine.ts alone, plus 6 deleted FSM test files totaling ~1,733 lines), the step:complete.validation event field, apps/atlasd/src/judge-runner.ts, and the validation pill UI (validation-pill-row.svelte + validation-accumulator.ts). Kept: complete tool injection when outputTo is set, failStep, document-store output capture from complete args. @atlas/hallucination dropped from deno.json, deno.compile.json, and 4 package.json workspace deps. The validation: field on WorkspaceMCPServerConfigSchema is also removed. (#297)
Bundled knowledge-hybrid agent removed in favor of the user-space @friday/agents/knowledge Python agent. The 2.3K-line in-tree implementation under packages/bundled-agents/src/knowledge/ (agent.ts, embed.ts, prompts.ts, rerank.ts, search.ts, shared.ts, store.ts + tests) is gone; the new Python agent on the public friday-agent-sdk (github.com/friday-platform/agents/knowledge) ships the same hybrid-RAG behavior — BM25 + vector + Groq reranker + Sonnet synthesis. Workspaces referencing agentId: knowledge-hybrid (type: atlas) must migrate to type: user, agent: knowledge; the registered id, not the knowledgeHybridAgent export name, is what workspace YAMLs reference. Drops the @ai-sdk/fireworks and @ai-sdk/groq dependencies from packages/bundled-agents/package.json and decouples knowledge-release cadence from the daemon. (#314)
Per-dispatch workspace runtime — --max-workspaces / --idle-timeout CLI flags and the entire daemon-level runtime cache are gone. Each signal dispatch in apps/atlasd/src/atlas-daemon.ts now constructs a fresh WorkspaceRuntime from the mtime-cached WorkspaceManager.getWorkspaceConfig and shuts it down on completion. The daemon-level runtimes Map, WorkspaceManager.runtimes, the idle-eviction subsystem, the chat-SDK instance cache (daemon.getOrCreateChatSdkInstance / evictChatSdkInstance), the runtime-invalidate callback chain, and the defer-until-idle file-watcher queue are all gone (-782 lines across 52 files). External callers that previously reached into a cached runtime now go through NATS: cancel publishes on daemon.cancel.sessions.<sid> (the daemon owns one wildcard subscription routing to a sessionId → AbortController table); sessions list reads from SessionHistoryAdapter.listInflight(workspaceId). The cancel subject is deliberately a sibling of sessions.> so the SESSION_EVENTS stream doesn't capture cancel commands. AtlasDaemonOptions.maxConcurrentWorkspaces / idleTimeoutMs and the matching CLI flags on atlas daemon start and restart are removed; DaemonStatus.configuration is removed; packages/workspace/src/mount-registry.ts is deleted (50 lines + 115-line test + 31-line cleanup test + 267-line watcher-suppress test). (#338)
FSM top-level functions: and tools: blocks deleted from the schema.FSMDefinitionSchema.functions and .tools in packages/fsm-engine/schema.ts were accepted by the schema but never executed: ActionSchema has no code variant, buildTools resolves only per-action LLMAction.tools against MCP servers, and no runtime path read definition.functions / definition.tools. The validator's empty-code branches in packages/fsm-engine/validator.ts and the functionCount / toolCount summaries in mcp-tools/{create-fsm,validate-fsm}.ts gave authors a false signal that these blocks were live. LLMAction.tools (per-action MCP allowlist) and MCP server tools.allow / tools.deny are unaffected. packages/system/workspaces/workspace.fsm.yaml (the only remaining declarer, marked legacy/quarantined and not loaded by packages/system/workspaces/mod.ts) is deleted. Pre-existing workspaces with these blocks still load cleanly. Fixes #226. (#282)
dispatcher field on browser→daemon fetch + same-origin proxy. Once TLS is enabled in <friday-home>/.env, browser→daemon traffic must route through the playground's same-origin proxy at /api/daemon/[...path] (and /api/tunnel/[...path] for the webhook-tunnel) — the daemon's private-CA cert is reachable only via processes that loaded <friday-home>/.env. External clients that previously reached the daemon directly over plain HTTP keep working when TLS is off; under TLS they must load the s2s CA bundle from FRIDAY_TLS_CA. (#243)
Added
HTTP/2 + TLS for the local dev playground via a split browser/s2s certificate chain. New scripts/setup-tls.sh (308 lines, idempotent, gated on SKIP_TLS=1 in setup-dev-env.sh) generates two parallel trust roots: browser.{crt,key} via mkcert for the playground origin (kept in the system + nss trust stores), and a private s2s.{crt,key} + s2s-ca.{crt,key} pair via openssl with a 1-year CA lifetime for the daemon, link, and webhook-tunnel listeners (never installed in any system trust store). The script writes FRIDAY_TLS_CERT/_KEY/_CA/DENO_CERT/NODE_EXTRA_CA_CERTS into <friday-home>/.env at chmod 600; SKIP_MKCERT=1 builds only the s2s chain for headless / CI. Once certs exist, the playground holds 3 long-lived SSE feeds + the Vite HMR socket over a single multiplexed HTTP/2 connection instead of pinning Chrome's 6-socket-per-origin HTTP/1.1 cap (two tabs previously left every fetch stuck in (pending) until a tab closed). (#243)
Same-origin browser→service proxies at /api/daemon/[...path] and /api/tunnel/[...path] so browsers never see the s2s cert. New tools/agent-playground/src/lib/server/proxy.ts extracts a shared buildProxyHandler + HOP_BY_HOP_HEADERS + proxyAbortableBody used by both the dev SvelteKit hook and the compiled-binary Hono path; strips HTTP/1.1 hop-by-hop headers (h2 rejects transfer-encoding et al) and propagates client-aborts. daemon-url.ts exposes daemonUrl() / tunnelUrl() returning same-origin paths, so every browser→daemon call routes through the one mkcert-trusted origin. New tls-paths.ts is the single cert-path resolver shared by vite.config.ts and static-server.ts; the dead __FRIDAY_CONFIG__ SSR runtime-config pipeline and the node-html-parser dependency are deleted as part of the same refactor. (#243)
download_tls_certs Tauri command + Extract.svelte install-time pip. New apps/studio-installer/src-tauri/src/commands/download_tls.rs (260 lines) fetches the Let's Encrypt-issued fullchain.pem + key.pem for local.hellofriday.ai from download.fridayplatform.io, sha256-verifies each artifact against manifest.json, and writes them to ~/.friday/local/tls/browser.{crt,key} — the same path tls-paths.ts resolves on the playground side. playground_url() (installer) and playgroundURL() (launcher) return https://local.hellofriday.ai:<port> when the pair is on disk so the wizard's "Open Studio" button and the tray menu land on the green-lock origin matching the cert SAN. local.hellofriday.ai is a public DNS name pointing at 127.0.0.1, so no /etc/hosts setup is needed. Failure is non-fatal — the installer falls back to http://localhost. (#243)
Launcher TTL-based browser-cert renewer. New tools/friday-launcher/tls_renewer.go (486 lines) parses the on-disk cert and triggers a manifest fetch when the cert is past 2/3 of its issued lifetime, expired, or missing. Downloads + sha256-verifies + atomic-writes the new pair; on change, calls RestartProcess("playground") so the playground re-reads cert files at startup. Boot-time synchronous attempt (5s budget) before sup.runAndWatch starts the playground so fast networks rotate without ever serving the old cert; async daily ticker for everyone else. tls-paths.ts parses each candidate cert and skips any pair where notBefore..notAfter doesn't cover now — with no usable pair the playground falls back to plain http://localhost, better UX than serving a browser-rejected cert. (#243)
Webhook-tunnel TLS support so settings + signal-row /status fetches work under https://.tools/webhook-tunnel/main.go loads <friday-home>/.env via godotenv (existing process env wins), config.go picks up FRIDAY_TLS_CERT/_KEY, and when both are set the http.Server uses ListenAndServeTLS; cloudflared's local origin URL follows the same scheme so the public hop is end-to-end TLS. The tunnel forwarder loads FRIDAY_TLS_CA into a *x509.CertPool on its http.Transport (cloned from http.DefaultTransport to keep pool / keepalive / proxy-env defaults), and config.go auto-upgrades http→https on FRIDAYD_URL when the CA is configured. godotenv promoted from indirect → direct in go.mod. (#243)
Launcher native TLS readiness probe per supervised service. New tools/friday-launcher/readiness.go (250 lines) runs a per-service readinessRunner with an http.Client whose tls.Config carries InsecureSkipVerify on loopback (we own both ends); HealthCache learns the verdict via SetReady and process-compose's ReadinessProbe is left nil. Process-compose's HttpProbe has no TLS knobs, so when daemon / link / webhook-tunnel / playground flipped to HTTPS the built-in probe failed every 2s and the supervisor bounced every service after 62s — the installer surfaced as "Some services are still starting" forever. The launcher-driven runner caps restarts at supervisedMaxRestarts (process-compose's MaxRestarts only gates its in-band restart loop, not external sup.RestartProcess calls), keeps probing past the cap so the cache reflects truth if the service recovers, and uses a badURLLogged guard so a malformed r.url emits one Error line then goes quiet. (#320)
Launcher .env URL-scheme migrator for stale http→https rewrites.cert_env.go::migrateStaleURLSchemes rewrites FRIDAYD_URL + EXTERNAL_DAEMON_URL + EXTERNAL_TUNNEL_URL + LINK_SERVICE_URL from http:// to https:// at boot when s2s certs are valid (the Tauri installer had pinned them to http:// before TLS support landed). Bidirectional: also downgrades https://localhost:N → http:// when s2sCertsValid()==false. Only flips launcher-managed loopback URLs — operator-pinned external https URLs pass through. startsWithLoopbackHost recognizes localhost / 127.0.0.1 / RFC 3986-bracketed [::1] (a hand-edited FRIDAYD_URL=https://[::1]:18080 is migrated). Preserves byte-for-byte comments, blank lines, and CRLF line endings (Friday Studio ships on Windows). (#320)
Google OAuth callback shim on plain HTTP for desktop installs. Gemini CLI's Cloud Function (the public exchanger Friday piggy-backs on for the verified consent screen) does a literal hostname check on state.uri — only localhost / 127.0.0.1 pass. The desktop install routes the playground origin through local.hellofriday.ai:15200 (the only name the browser-trusted cert covers), so the callback URL was rejected and users landed on the Cloud Function's manual JSON-paste page. The playground now binds a plain-HTTP shim on 127.0.0.1:<PLAYGROUND_OAUTH_SHIM_PORT> (playground port + 1) that 302-redirects /api/daemon/api/link/v1/callback/* to the TLS origin; Link's delegated flow rewrites the state.uri host via the new FRIDAY_OAUTH_SHIM_BASE env var so the Cloud Function accepts and the browser hops back to the browser-trusted origin transparently. The shim listener is wrapped in try/catch + onError so an AddrInUse doesn't crash-loop the playground. (#320)
discardBody(res) helper in @atlas/utils for early-return fetch paths. New packages/utils/src/fetch-drain.ts exports async function discardBody(res: Response): Promise<void> that awaits res.body?.cancel() inside a try/catch that swallows "stream already disturbed" errors so it's safe to call from defensive if (!res.ok) branches that might race with an upper-layer consumer. Lives in @atlas/utils (not a per-package helper) so the same import works from packages/, apps/, and tools/, and the grep target stays unique across the codebase for future audits. Threaded through 17 production files (~45 fix sites) including packages/core/src/agent-context/compose-blocks.ts, packages/core/src/credential-fetcher.ts, packages/core/src/mcp-registry/upstream-client.ts, packages/llm/src/model-catalog.ts, every packages/mcp-server/src/tools/* site, the bundled-agents (hubspot/tools.ts, web/tools/fetch.ts), workspace-chat tools (mcp-discovery-tools.ts, memory-entry-tools.ts, memory-save.ts, web-fetch.ts, web-search.ts), packages/memory/src/discovery-to-task.ts, and packages/skills/src/skills-sh-client.ts. (#340)
Home-dir isolation for the NATS broker: each install picks its own port from a Friday-reserved range. Running deno task atlas (dev) alongside Friday Studio.app (prod) on the same machine no longer cross-contaminates state. Each install picks its own port from the 14222-14231 range and writes the chosen URL to <home>/nats/url so siblings can find it; daemon, CLI, and launcher all discover the broker through that file instead of hard-coding :4222. NatsManager.start() now exposes its resolved URL via a url getter parallel to connection, and AtlasDaemon.getNatsUrl() matches the existing getNatsConnection() so process-agent-executor and the agents/register route take the URL from the daemon and pass it explicitly into the spawn env. connectOrSpawn enforces spawnFallback: false regardless of how the URL was resolved (the prior provenance refactor checked it only on the explicit/env paths, so friday migrate --no-spawn against a home with a stale URL file would silently spawn an ephemeral broker). The default home for a bare friday binary is now ~/.friday/local (matches the installer/launcher convention); deno task atlas pins FRIDAY_HOME=$HOME/.atlas in deno.json so dev behavior is unchanged. New ENVIRONMENTS.md (293 lines) lands as the operator-facing config reference. (#296)
Cross-home workspace filter so foreign-home entries can't be mounted, watched, or mutated. New packages/workspace/src/__tests__/cross-home-filter.test.ts (547 lines) covers the contract: workspaces registered under a different FRIDAY_HOME (artifact of historical cross-contamination) are now filtered out at runtime rather than mounted, watched, or operated on. Lookups by id / name / path mask cross-home entries; deleteWorkspace (the rm -rf case), renameWorkspace, updateWorkspacePersistence, and restartSignalsForWorkspace all refuse to touch them. Entries stay in KV, and generateUniqueId's walk still sees them so we never re-mint a colliding id. The cwd-.atlas heuristic at the bottom of getFridayHome() is gone. (#296)
Five @friday/debugging-* system skills + a <diagnostic_load> directive that routes symptoms to them.debugging-broken-jobs/SKILL.md is the entry-point triage table; debugging-empty-output covers status: completed sessions with empty summary / artifactIds; debugging-tool-loops covers stepCountIs cap with no terminal text; debugging-runtime-errors is an error-pattern → fix table for did not call complete, Invalid job config, schema mismatches; debugging-job-invocation covers bare-{} job calls, Pattern A vs B distinction, and the manual signal-name pitfall. A new <diagnostic_load> block in workspace-chat/prompt.txt routes the observed tool-result signature to the matching skill BEFORE the agent forms a hypothesis. (#297)
@friday/agent-action-handshake system skill — canonical FSM action ↔ agent prompt ↔ runtime injection contract. New 174-line packages/system/skills/agent-action-handshake/SKILL.md covering all four invocation kinds, what each kind auto-injects, the outputTo + complete rule, and inputFrom document chaining. The workspace-chat prompt now carries a one-liner directing agents to load this skill once before authoring or editing any agent/job — it's the single source of most "empty output" misdiagnoses. (#297)
@friday/delegate-handoff system skill teaching delegate({skills: [...]}) skill pushing. Documents that delegate sub-agents have NO load_skill tool and NO available-skills index of their own, so the parent must inject domain knowledge via the skills: arg; covers good-handoff style, the budget envelope, and when to delegate vs call inline. A new <delegation_with_skills> directive in the chat prompt makes it a hard requirement when the child needs domain knowledge. (#297)
AGENT_HONESTY_DIRECTIVE + DESTRUCTIVE_TOOL_GUARD shared blocks injected into non-chat agent prompts. New packages/core/src/agent-context/honesty-directives.ts exports two pure-string directives. Layer A (HONESTY: sentinel) is a sourcing rule for output prose — every factual claim traces to a tool result, your input, or direct logical inference; tool errors aren't paraphrased as facts; impossible tasks fail via failStep / ok:false rather than deliver placeholders. Layer B (DESTRUCTIVE-TOOL GUARD: sentinel) is a pre-flight check on every argument of write/send/create/modify/delete tools after a live send_gmail_message({to: "ljagiello@zmail.com"}) call invented the address. The guard's "ask via request_human_input if you have it, otherwise return ok:false" conditional resolves the per-surface mechanism without forking the wording; injected into delegate children (packages/core/src/delegate/system-prompt.ts re-exports DELEGATE_HONESTY_BLOCK) and session-supervisor (Layer A only — no tools). The new tools/qa/live-daemon/scenarios/honesty-directives.ts static eval pins both sentinels at every expected injection site. (#297)
<destructive_tool_guard> block scoped to outbound MCP tools in workspace-chat. Adds the same DESTRUCTIVE-TOOL GUARD: sentinel to workspace-chat/prompt.txt but explicitly narrowed to email/slack/calendar/github/drive-class outbound MCP tools — job-tools and platform tools are out of scope. Includes a concrete failure-mode example ("send Lukasz an email" + gmail search returning only github-notification noise → DO NOT send to ljagiello@<plausible-domain>.com). (#297)
<secret_handling> directive — never solicit credentials in chat. New block in workspace-chat/prompt.txt with a SECRET-INPUT GUARD: sentinel forbidding the agent from asking the user to paste, type, or send a password / API key / access token / client secret into chat (chat is not a secure channel — messages are stored and rendered like any other). Credentials route through connect_service's dedicated card; if the user offers to paste a secret the agent stops them and points at connect_service; if a service genuinely has no connect flow the agent says so plainly rather than collect the secret in chat as a workaround. New tools/qa/live-daemon/scenarios/secret-input-guard.ts covers four behavioral cases (unconnected service, user offering to paste a key, innocuous "password" mention, a service with no connect flow run as a with/without-directive causal pair). (#318)
<runtime_bug_discipline> directive forbidding unsubstantiated "platform bug" claims. New block in workspace-chat/prompt.txt requiring the agent to call describe_session, read agentBlocks[].toolCalls, and confirm the contracted exit (complete for outputTo; terminal text otherwise) was attempted before claiming a platform bug. Gates the diagnostic discipline the new debugging-* skills are built around. (#297)
Workspace .env file is now a real runtime overlay across every spawn site. New packages/workspace/src/workspace-env.tsloadWorkspaceEnv() reads workspaces/<id>/.env lazy-on-write — an absent file is a valid empty overlay, never cached (the file is editable at runtime). resolveEnvValues and the agent-context environment validator both grew an overlay parameter; a new shared readEnvVar(key, overlay?) helper defines the precedence rule once. The overlay threads to every workspace spawn path — user agents, atlas/llm/system agents (via the agents-server call chain), FSM type: llm steps (via a getEnvOverlay thunk), the /agents/run route, and delegate sub-spawns through a new envOverlay field on AgentContext and DelegateDeps. Final precedence at spawn: ambient process.env → workspace .env → per-agent / per-server env: wiring (most specific wins). Workspace creation no longer pre-drops a placeholder .env — the file appears on first write. (#323)
Agent-callable env_list / env_get / env_set / env_delete platform tools. New packages/mcp-server/src/tools/env/* registers four MCP-side tools for reading and writing workspace and (dev-mode) global .env values — the supply side of chat-driven workspace configuration. Reads are cross-workspace; writes are scope-injected so a workspace write only ever targets the agent's own workspace. env_list / env_get mask secret-looking keys (/token|key|secret|password|credential/i) before the value reaches an LLM. env_set does NOT write directly — a chat turn cannot block on human input, so the tool raises an env-write elicitation, returns pending_confirmation immediately, and the daemon commits the (possibly multi-var) write server-side when the user confirms the chat card; the commit runs BEFORE ElicitationStorage.answer so a failed write keeps the elicitation pending instead of cheerfully showing "Applied." env_set also refuses the masked sentinel (********) as a value so a read-then-write agent can't round-trip a masked env_get into a corrupting write. Backed by new daemon routes /api/workspaces/:id/env[/:key] (workspace) and /api/config/env/:key (global, dev-gated, hot-reloads process.env) — both line-based and comment-preserving. (#323)
env_set confirmation card in chat. New tools/agent-playground/src/lib/components/chat/env-set-tool-card.svelte (361 lines) renders standalone (outside the collapsible tool-burst) via needsUserAction so the call never migrates between burst and standalone as it settles. Stable DOM: a fixed placeholder while the tool call is still streaming, then the full card mounts once in a single transition with no per-chunk height churn in the virtualized message list. Matches the env-write elicitation by id from the tool output (no question-text heuristic). Shows the scope + each proposed key/value; secret-looking keys (/token|key|secret|password|credential/i) get a masked value with eye-toggle reveal, plain keys show directly. Confirm / Deny submit via the existing answer mutation; terminal state (applied / denied / expired) reads off elicitation status and survives reload. (#323)
Per-workspace settings page at /platform/[workspaceId]/settings/[[section]]. New ListDetail page with three URL-routed sections — /settings (Workspace Details, edits the workspace: block via a new updateWorkspaceIdentity config mutation + PUT /config/identity route gated by requireWorkspaceAdmin), /settings/agent-environment (edits the values of each agent's env-backed keys, writing through the per-key workspace .env routes; linked-credential and literal wirings render read-only), and /settings/mcp (enable/disable servers, edit each enabled server's setting values, pick Link credentials for Link-backed env vars). Sidebar nav uses <a href> rather than <button onclick> so back / forward / middle-click / link-sharing work; the parent layout's isSettings matcher is updated to the new route id so the workspace right-sidebar (integrations, recent runs) doesn't leak onto settings. The standalone /platform/[workspaceId]/mcp/+page.svelte (717 lines) is deleted — its discovery surface was unwanted; the lean MCP section composes onto this settings page instead. (#323)
MCP server detail page at /mcp/[[serverId]]/[[section]] redesigned as a section-routed drill-down. Rebuilt mcp-server-detail.svelte (1353 lines) as a ListDetail whose middle column swaps animatedly between MCPCatalogTree (when no server is selected) and a new mcp-section-nav.svelte (when one is). Sections each get their own route: Overview (folds in the doctor TL;DR, transport, env-var provenance), Connections (credentials panel + "Enabled in these workspaces" list with real links to each workspace's MCP settings), Config Reference (read-only env-var table with sub-titles + "Raw config" sub-block), Testing (the new tool invoker — merge of the old Tools + Test sections), and Readme. Server names strip leading reverse-DNS segments (io.github.owner.repo → owner/repo) via a shared shortenServerName util used by the catalog list AND the section nav. The old mcp-test-chat.svelte (317 lines) and mcp-connection-test.svelte (241 lines) are deleted — superseded by Testing's raw tool invoker. (#323)
mcp-tool-invoker.svelte — raw tools/call invoker against a connected MCP server. New 545-line component lists the server's tools, expands input schemas inline, and renders a schema-driven form for each. Calls a new POST /api/mcp-registry/:id/invoke route (always under a workspaceId, always requireWorkspaceMember-gated) that does tools/call on a connected server with the daemon's session and returns the actual output for inspection. The /invoke and /test-chat Link-summary fetches now pass getLinkAuthHeaders() (exported from credential-resolver) — previously unauthenticated and would have silently 401'd in production mode. (#323)
MCP registry install-time setup doctor. When a registry server is installed, a new doctor classifies it (self-contained / needs-review / needs-investigation) via a single LLM call that extracts env vars with verbatim-name verification against the README — fetched from the entry's repository, falling back to a URL derived from the canonical name. Transport-correct env routing: required vars and any secret route to Link credential refs; optional non-secrets become plain strings carrying their upstream defaults. PyPI stdio packages install via uvx alongside npm / npx. Install is a multi-step flow — preflight, a background doctor run, then commit / cancel / manual-config — with an SSE progress stream; the doctor never throws. Catalog enable and per-workspace enable are gated on install status; the catalog search is filtered to installable entries and shows each entry's last-updated time. A code-shipped annotation overlay feeds curator hints into the prompt. (#323)
Copy-on-enable literal/wiring split for MCP servers. Enabling a server now lifts every literal setting value out of the registry template into the workspace .env (the store the new settings UI edits) while the config copy keeps from_environment wiring only. The split runs after the enable mutation lands and never clobbers an existing .env value. The MCP settings section's editable fields write through a new setMCPServerEnvWiring mutation that migrates legacy literals (servers enabled before the split) on first save — every string env value is now editable. (#323)
trigger_signal workspace-chat tool — fire any workspace signal from inside chat. New generic escape-hatch tool registered by createJobTools in packages/system/agents/workspace-chat/tools/job-tools.ts after the per-job binding loop. Per-job tools are built from a config snapshot taken when the chat session started, so a job created mid-session via upsert_job had no callable tool until the next session — trigger_signal({ signalId, payload? }) closes that gap by firing any signal by id with an arbitrary payload and blocking until the spawned job completes (payload fields land as {{inputs.<field>}} in FSM action prompts). Routes through executeJobViaSSE when a chat writer is present, executeJobViaJSON otherwise. The loop now continues on a workspace job literally named trigger_signal with a warn log so the generic tool's name is never silently shadowed — the job stays runnable through trigger_signal itself. Workspace-chat prompt.txt and the debugging-broken-jobs / debugging-job-invocation skills are rewritten to teach the new contract: "a job you just created this session is NOT bound yet — run it with trigger_signal." (#321)
Bundle export now includes globally-installed user agents referenced by workspace.yml. New resolveExternalUserAgents in apps/atlasd/routes/workspaces/bundle-helpers.ts walks every type: user entry in the workspace config, resolves each to its install dir under <userAgentsDir>/<id>@<version>/ via UserAdapter.loadAgent, and passes the resulting { name, sourceDir }[] through a new ExportOptions.externalAgents field on packages/bundle/src/bundle.ts#exportBundle. The external agents are embedded under agents/<name>/ and pinned in the lockfile like a workspace-local agent (which still wins on a name conflict). Previously exportBundle walked only <workspaceDir>/agents/, so bundles for workspaces referencing globally-installed user agents shipped with the reference but no definition. Unresolvable refs are collected into a single Workspace references user agent(s) that could not be resolved under <userAgentsDir>: <ids> throw rather than silently dropped. (#288)
importGlobalSkills round-trips the JetStream skill store. New primitive in packages/bundle/src/global-skills.ts re-publishes user skills into the JetStream SKILLS KV + SKILL_ARCHIVES Object Store via SkillStorageAdapter. Idempotent re-import: rows whose skillId already exists at version >= row.version and same namespace are skipped without reading archive bytes — mirrors the May-3 migration's skip logic so a partial re-import is safe and cheap. Two-tier integrity check: outer sha256 over skills.jsonl plus a per-archive sha256 for each row that owns archive bytes; either mismatch returns { kind: "integrity-failed", expected, actual, row? } and aborts further publishing. disabled flag preserved via setDisabled() after publish. Manifest with source.filename === "skills.db" (pre-May-3 export) surfaces LegacyArchiveError mapped by the route to { kind: "legacy-archive-rejected" }; the settings UI translates that new status into an actionable label pointing the user at the remediation (re-export from the source daemon). (#242)
Per-block Anthropic system messages extracted to buildAnthropicSystemMessages. Inline IIFE in workspace-chat.agent.ts is now a pure exported function unit-tested for ordering, per-block TTL (block1 / block2 at 1h, block3 / block4 at 5m), the empty-block2 / block3 skip branches, and the non-increasing-TTL invariant Anthropic requires across the tools → system → messages sequence. A regression that swapped a TTL, dropped block 4, or reordered the blocks would otherwise have shipped with every test green. (#321)
Chat fullscreen mode via Ctrl+F + header download-icon button. New fullscreen = $state(false) in user-chat.svelte toggled by Ctrl+F (deliberately Ctrl, never ⌘, so Mac's ⌘+F find-in-page is untouched — tradeoff: on Windows/Linux it shadows browser find inside the chat surface) and an .icon-button in the chat header; Escape exits. The Export chat text pill in the header is replaced by a download glyph (.icon-button with a viewBox="0 0 16 16" arrow-down-into-tray SVG) sized to match the new fullscreen button. (#313)
Collapsible chat sidebar with Ctrl+B and localStorage persistence.packages/ui/src/lib/list-detail/list-detail.svelte gains a bindable sidebarCollapsed?: boolean prop (default false — existing consumers see no change) that slides the sidebar to a thin peek strip with a chevron <button> (aria-label="Expand sidebar"); clicking the strip or pressing Ctrl+B brings it back. The inner content is wrapped in a <div class="sidebar-inner" inert={sidebarCollapsed}> so keyboard / screen-reader users can't land on invisible controls during the 240ms cubic-bezier(0.4, 0, 0.2, 1)inline-size transition; overflow: hidden on .sidebar clips the inner content during slide so no scrollbar flashes mid-animation. State persists across reloads. The skills / discover / MCP sidebars are unaffected — they keep their own scroll and bottom spacing. (#313)
Daemon-gate leapfrog loading animation + Retry Now button on lost-connection. New daemon-loading.svelte component (168 LOC) animates the Friday F-mark (D piece ~0.8× rect proportions, brand blue #1171df) leapfrogging over itself in place — locked centered via a counter-scrolling track. The lost-connection state in daemon-gate.svelte now reuses the same loader (with a small Retry Now button that calls checkDaemonHealth()) instead of a dedicated ! icon + title + message panel. New daemon-loading-message.svelte ("Connecting…") shared by both states. Loader fades itself in over 1s on mount so a fast daemon connect no longer flashes a half-rendered loader; the gate cross-fades between loading and content branches over 400ms (out:fade / in:fade) so the transition into the app stops feeling janky. prefers-reduced-motion: reduce falls back to a static render. (#298)
Chat-input drag-and-drop accepts text-file artifacts and renders them as <ArtifactCard> chips. Dropping a .md / .json / .csv / .log / .txt / .html (or any other ALLOWED_EXTENSION) onto the chat input now uploads as an artifact instead of being silently rejected — twenty previously-uploadable: false extensions (.html, .xml, .ts, .py, .css, .log, .toml, .sh, .tsv, .conf, …) flipped to uploadable: true in EXTENSION_TO_MIME since the artifact-card already renders them safely (text/* → inline <pre> preview; text/html → sandboxed iframe with allow-scripts but no allow-same-origin). The user bubble surfaces the same <ArtifactCard> agent-produced artifacts use — first-N-rows table preview for CSV/JSON/HTML, sandboxed iframe for HTML, lazy PDF preview, image tag for images, Download + Open buttons. Images stay on the existing data-URL type: "file" path so vision still works without a tool roundtrip. SVG remains uploadable: false and is also refused in classifyAttachment as defense-in-depth (script-injection risk via inline <script>), with new 415 rejection tests in apps/atlasd/routes/artifacts.test.ts and chunked-upload.test.ts. (#292)
<attachment …> text-part inlining so the agent reads attached file content without an extra tool call. The atlas-web adapter mutates the persisted uiMessage to insert <attachment …>…</attachment> text parts inline so per-turn history reads pick up the bytes; the parts are marked providerMetadata.atlas.kind = "attachment-expansion" and buildSegments hides them in the bubble (replaces the prior regex-on-tag-shape detector that would have hidden any user-typed text matching <attachment … artifactId="…">). Non-text mimes (PDF/DOCX/audio) get a bare reference tag; the agent fetches them via read_artifact / parse_artifact. The drop event from the inner chat-input no longer bubbles to the outer user-chat handler (pre-existing duplication bug surfaced by QA on text attachments — fix benefits images too). (#292)
POST /api/scratch/upload + read_attachment tool — chat attachments land on the filesystem, not the workspace library. Pivot away from artifact-system uploads to avoid polluting the workspace library with chat-scoped reads. New apps/atlasd/routes/scratch-upload.ts writes bytes to {FRIDAY_HOME}/scratch/uploads/{chatId}/{filename} with chatId + filename traversal guards, the same mime/size allowlist as the artifact route, and a 201 {path, filename, mediaType, size} response. packages/system/agents/workspace-chat/tools/file-io.ts adds createReadAttachmentTool(chatId, logger) scoped to attachmentsRoot(chatId) — resolveAttachment() rejects non-absolute paths or paths escaping the per-chat root. Adapter emits self-closing <attachment path="…" filename="…" mediaType="…" /> text parts (no bytes inlined); the chat-message-list renders a simple .user-file-chip per path (icon + filename + mime badge) instead of the full <ArtifactCard>. packages/agent-sdk/src/messages.ts adds a data-file-attached schema {paths, filenames, mimeTypes} and packages/core/src/chat/export/render.ts replaces the artifact-list segment with a file-list segment. (#292)
chat-attachment.ts shared helper, multi-file rejection coalescing, and rejection toasts. New tools/agent-playground/src/lib/components/chat/chat-attachment.ts extracts classifyAttachment(file) (image vs file vs unaccepted), buildFileAttachment(file), runFileUpload({att, chatId, workspaceId, onUpdate}), and rejectionReason(file) so chat-input.svelte and user-chat.svelte stop running line-for-line identical pipelines. A rejectionToast() helper coalesces multi-file rejections into a single summary toast — drops the "drop 50 files → 50 stacked toasts" failure mode. Single-file rejection still surfaces the specific reason (SVG keeps "script-injection" copy; unknown extensions enumerate the new ACCEPTED_TYPES_DESCRIPTION derived from a single ACCEPTED_CATEGORIES map in file-upload.ts covering Text/markup, Source code, Documents, Images, Audio). 32-case chat-attachment.test.ts covers each branch. (#292)
Per-dispatch workspace runtime — chat-authored jobs are callable in the same turn. Each signal dispatch in apps/atlasd/src/atlas-daemon.ts now constructs a fresh WorkspaceRuntime from the mtime-cached WorkspaceManager.getWorkspaceConfig and shuts it down on completion. A chat agent that calls upsert_job (or publish_draft) mid-turn can immediately invoke the new job via trigger_signal in the same turn — previously the cached runtime's this.jobs map was baked at construction and the new mapping wasn't visible until the runtime got torn down, which the defer-until-idle queue often raced against. New apps/atlasd/src/session-dispatch-registry.ts (188 lines) owns the AbortController table. The trigger-signal description in job-tools.ts updates "same chat session" → "same chat turn" to match the new semantics. (#338)
Inflight session marker carries workspaceId + signalId for filter-without-join.SESSION_INFLIGHT KV marker shape in packages/core/src/session/jetstream-session-history-adapter.ts gains optional workspaceId and signalId fields, written from appendEvent on the session:start event so external listers can filter by workspace without a separate lookup. (#338)
Regression test pins the signal-trigger abort → cancel-frame wiring. New 187-line addition to apps/atlasd/routes/workspaces/index.test.ts covers both JetStream branches (JSON + SSE variants): drives a real NATS-backed request, aborts the client before any response lands, and asserts a signals.cancel.<correlationId> frame arrives within 2 seconds. Subscribes to signals.cancel.* (strict single-token match) and asserts the subject matches the UUID regex, locking the correlationId format down explicitly. Verified by mutating each onClientAbort listener in turn — dropping cancelCorrelatedSignal() from either branch trips the corresponding test with a 2-second timeout. Closes #233. (#283)
@friday/agents/knowledge user-space replacement for the bundled knowledge-hybrid agent. The user-space Python agent on friday-agent-sdk ships the same hybrid-RAG behavior — BM25 + vector + Groq reranker + Sonnet synthesis — and is exercised end-to-end by tools/qa/live-daemon/scenarios/knowledge-agent.ts. Drops the @ai-sdk/fireworks and @ai-sdk/groq dependencies from packages/bundled-agents/package.json (the user-space agent goes through ctx.http directly) and decouples knowledge-release cadence from the daemon. (#314)
buildExecutorTimeoutOption(jobTimeoutMs) + exported parseJobTimeoutMs helpers in packages/workspace/src/runtime.ts. Tiny pure helpers extracted so the conditional-spread shape feeding ProcessAgentExecutor.execute is unit-testable without standing up a full WorkspaceRuntime. Contract is pinned: undefined => {} (executor uses its 180s default), positive number => { timeoutMs: N }, 0 => { timeoutMs: 0 } so a careless refactor swapping !== undefined for truthy can't silently drop the field. Five tests in runtime-job-timeout.test.ts cover the shape; parseJobTimeoutMs is now exported and itable-driven it.each(["0s","0m","0h"]) cases exercise the <= 0 rejection branch via genuinely-parseable zero values ("0ms" would fall through parseDuration's unit allowlist). (#314)
Changed
System block layout split: <workspace> XML moved out of block 2 into a new block 4.getSystemBlocks in packages/system/agents/workspace-chat/workspace-chat.agent.ts now returns four logical tiers — block1 (weeks-stable static prompt.txt), block2 (workspace-stable identity: skills index + user identity), block3 (session-stable: onboarding + user profile), block4 (volatile workspace inventory: agents, signals, jobs, MCP servers). Block 4 was previously co-located with block 2's 1h-TTL identity tier, so every upsert_* / publish_draft burned a full-rate cache write; the new shape gives block 4 the cheaper 5m TTL. The cache salt still rides on block 2 (not block 4) so a "force fresh" bump cascades through everything behind it — salting only block 4 would leave the skills / identity breakpoint cached. Anthropic enforces non-increasing TTL across the sequence (1h, 1h, 5m, 5m). flattenSystemBlocks is updated to thread all four parts; the b4Preface (memory + temporal facts) is renamed turnPreface everywhere to disambiguate from the new system block 4 — the preface remains a synthetic user message at position 0, not a cache breakpoint. (#321)
Workspace-chat invalidateBlock2 now fires on every config-mutating tool.invalidateBlock2(effectiveWorkspaceId) is called on the 200 branch of upsert_* (direct AND draft writes — cheap, and keeps the cache honest if the config endpoint ever surfaces draft state), publish_draft, enable_mcp_server, and disable_mcp_server (both default and force: true branches) in packages/system/agents/workspace-chat/tools/. The function existed for exactly this but was never called outside its own test, so for up to 5 minutes after an upsert_* / publish_draft the chat ran on a stale view — a job it just created wasn't callable and a signal it just changed still validated against the old schema. The invalidateBlock2 docstring in block2-cache.ts previously listed "skill assignment events" as a caller; skills and user identity are fetched fresh every turn, not cached in this entry, so their tools deliberately don't invalidate — the docstring now says so and lists the real callers. (#321)
load_skill results exempt from artifact scrubbing. New SCRUB_EXEMPT_TOOLS = new Set(["load_skill"]) set in packages/core/src/artifacts/scrubber.ts short-circuits liftToolResultsForPersist AND scrubAssistantMessage for load_skill calls. The tool's entire purpose is to inline a skill's body (instructions + reference files) into the prompt — lifting that body to an artifact defeated the tool: the agent "loaded" a skill and got back a [lifted to artifact] marker instead of the guidance it asked for. The exemption also covers delegate sub-agent output chunks: a new pre-pass builds a Map<"<delegateToolCallId>:<toolCallId>", toolName> from data-delegate-chunk envelopes (keyed compositely because inner toolCallIds are only unique within a single delegate's stream — two sibling delegates could otherwise collide on the bare id), and the chunk scrub branch looks up the tool name before deciding whether to scrub. (#321)
Workspace-chat prompt no longer claims config.tools is the "complete tool surface."workspace-chat/prompt.txt and the matching skills (writing-workspace-jobs, workspace-api, upsert_agent tool description) are corrected: config.tools is the MCP-tool allowlist, and platform tools (memory + artifacts + fs + HITL + flow-control — full set in packages/agent-sdk/src/platform-tools.ts, ~35 entries) auto-inject on top of the allowlist regardless of what's listed in tools:. Stale memory_save / artifacts_create / artifacts_get / get_mcp_dependencies references in skills (renamed in the 2026-05 verb-first sweep) are also stripped. (#297)
Workspace-chat dropped the static <user_profile> block. The block told the agent "don't know name → ask early"; that role moved to the programmatic onboarding flow some time ago (workspace-chat.agent.ts injects an <onboarding> clause via buildOnboardingClause whenever UserProfileState.status === "unknown" with explicit set_user_identity wiring). The static line was duplicating intent and biasing the model toward an onboarding greeting on the first message even when the runtime had its own plan. (#297)
<diagnostic_load> / <runtime_bug_discipline> / <delegation_with_skills> blocks tightened. Each was ~12-line bullet blocks; shrunk to one or two sentences each, keeping every load-bearing phrase the skill-catalog-and-directives eval pins and dropping the discursive prose. Net -32 lines across the four directive blocks; the chat surface reads like an interactive workflow guide again rather than a rulebook. (#297)
buildSessionJobResult never returns an empty summary field. Every fallback synthesis is now wrapped in nonEmptySummary + EMPTY_SESSION_SUMMARY sentinel so the SSE job-complete.summary field never carries an empty string for the chat-tool wrapper to drop. Empty sessions read "Session completed without producing a summarizable document. This usually means the agent's prompt did not call complete() or the FSM had no outputTo action." instead of silently surfacing summary: "" / artifactIds: [] to the chat. (#297)
Delegate sub-agent system prompt now carries the universal honesty + destructive-tool guard. New DELEGATE_HONESTY_BLOCK exported from packages/core/src/delegate/system-prompt.ts concatenates AGENT_HONESTY_DIRECTIVE + DESTRUCTIVE_TOOL_GUARD, spliced into the child's system prompt alongside buildDelegateScopeDirective and DELEGATE_MCP_ERROR_CONTRACT. When the parent is workspace-chat (no request_human_input in the inherited tool surface), the guard's "otherwise return ok:false with a reason" clause fires; when the parent is an FSM action (request_human_input inherited), the elicitation clause fires. (#297)
Atlas-agent env wiring resolves through the single shared resolver.environment-context.ts carried its own hand-rolled Link credential resolution separate from resolveEnvValues; both are now collapsed into one path. Regex validation, LITELLM substitution, and the missing-config aggregation stay (those are validation, not resolution). Provider-only refs now resolve the provider's default credential instead of the first one from the summary list. Optional-var linkRefs, previously accepted by the schema and silently dropped, now resolve. AtlasAgentConfigSchema.env (per-agent env wiring from workspace.yml) was accepted by the schema and never threaded through — it now rides AgentToolParams.env across the agents-server boundary, resolves via resolveEnvValues at buildAgentContext, and merges into AgentContext.env over the environmentConfig-derived vars. (#323)
Daemon degrades gracefully when an MCP server has unset env vars.validateMCPEnvironmentForWorkspace previously threw on unresolved from_environment env vars, which aborted the entire workspace runtime creation; with no runtime the workspace couldn't spawn a chat at all, so the user had no way to set the missing vars from inside chat — the workspace was effectively bricked until a manual .env edit. The daemon now catches MissingEnvironmentError, clones the merged config (getWorkspaceConfig hands back the cached MergedConfig by reference — in-place mutation would keep the server dropped past a .env fix until a workspace.yml mtime bump), drops just the offending MCP servers from the clone, and lets the runtime come up degraded. The failure surfaces on workspace metadata the same way agent-registration failures already do; MissingEnvironmentError carries a structured missingVars list so the daemon enumerates offenders instead of string-matching the message. (#323)
resolveEnvValues soft-binds unset from_environment / auto sentinels to "" instead of throwing.splitLiteralEnvValues liberally rewrites every plain-string env value in a registry template (including optional empty-default literals like BITBUCKET_LOG_DISABLE) into the from_environment sentinel and lifts the literal into the workspace .env. The previous throw punished vars the user was never expected to set; genuine required vars are enforced upstream by the install-time RequiredConfigField flow. Concrete failure mode: bitbucket-mcp listRepositories failing with BITBUCKET_URL not found even when the URL was set, because the /invoke and /test-chat routes called createMCPTools without the workspace .env overlay — those routes now thread envOverlay too. (#323)
mcp-registry provider hydration emits standard JSON Schema 2020-12 with per-field metadata. Replaces the flat Record<string, "string">secretSchema shorthand from buildApiKeyProvider with per-field SecretFieldDescriptor objects carrying isRequired, isSecret, and (when present) description from the upstream env vars. DynamicApiKeyProviderInputSchema.secretSchema at the Link wire boundary is widened to accept either the legacy Record<string, "string"> flat shape or the new Record<string, "string" | {type, isRequired?, isSecret?, description?}> descriptor record. hydrateDynamicProvider's apikey branch lifts description via .describe(), isSecret via .meta({ format: "password", writeOnly: true }), and non-required fields via .optional(), assembling a z.object(...) so required[] falls out implicitly — GET /v1/providers/:id now emits real JSON Schema 2020-12 instead of a flat blob. The credential secret form in the playground reads each property's description as helper text below the input, tags non-required fields with an inline neutral "optional" badge, and prefers format === "password" for masking with the existing /password|secret|token|key/i regex as a back-compat fallback. (#323)
Combobox component promoted from the welcome route to lib/components/shared/. Used by the workspace settings MCP section's quick-add dropdown and by the existing /welcome wizard. A long-standing one-char-back typing bug is fixed: the external-value sync $effect ran before the free-form typing $effect within a flush, saw value still lagging $inputValue, and reset the input back a character on every keystroke past the first. Guarded the sync effect on !$touchedInput — once the user is typing, the free-form effect owns the sync. Also stubs $app/paths for vitest (mirrors the existing $app/environment stub) so route-component tests that pull in a component calling resolve() don't fail to resolve the SvelteKit virtual module. (#323)
enable_mcp_server / disable_mcp_server invalidate three query caches. The MCP enable/disable mutations on the workspace settings page now invalidate the MCP status query, the workspace config query, AND the workspace .env query — previously only the MCP status was invalidated, so the new server's settings wouldn't render until a manual refresh. (#323)
Credential add via useCreateApiKeyCredential invalidates the linkProviderQueries.* tree. The add-credential flow lived as a raw fetch inside useCredentialConnect.submitApiKey that never touched the tanstack cache, so the credentials list stayed stale until a manual refresh. Delete and replace already worked because they were createMutation hooks with onSuccess: invalidate; create is now promoted to the same shape and called from three sites (mcp-credentials-panel, connect-service, communicator-apikey-form). Also factors a readErrorMessage(res, fallback) helper backed by z.object({ message?, error? }).safeParse(...) to handle Link's inconsistent error shape. (#323)
Bare signal-trigger bodies rejected with a clear envelope-shape error. New detectUnwrappedSignalBody + SIGNAL_ENVELOPE_KEYS set in apps/atlasd/routes/workspaces/index.ts reads the parsed JSON body (Hono caches it, so c.req.json() is re-readable after zValidator consumed it) and, when payload is absent but unrecognized top-level keys are present, returns a 400 naming the envelope shape (Signal body must wrap the payload: {"payload": {...}}. Received un-enveloped top-level key(s): X — wrap them as {"payload": {"X": ...}}). Without the guard a bare body parsed cleanly — every field of signalBodySchema is optional — leaving payload undefined; that undefined then surfaced downstream as a misleading "expected , received undefined → at " error pointing at the signal's own schema rather than the missing envelope. Empty bodies still pass. Wired into both the JSON and SSE handlers. (#321)
Workspace-chat session-supervisor and judge-agent invocations no longer go through cached chat-SDK instances.daemon.getOrCreateChatSdkInstance caching and daemon.evictChatSdkInstance are gone; the SDK is now built per-call. Dropping the cache is the same move the rest of the runtime tear-out makes — the cached map's invalidation contract was a constant source of staleness-vs-races for very little win. (#338)
Workspace deletion redirects to the personal workspace.goto("/platform") after deleteMut.mutateAsync(workspaceId) in tools/agent-playground/src/routes/platform/[workspaceId]/+page.svelte is replaced with goto("/platform/user") — the personal workspace (id="user") is canonical and cannot be deleted, so it always exists as a landing target. (#312)
"Remove workspace" overview-menu item hidden on canonical workspaces. Threads a new optional canonical: "personal" | "system" field through WorkspaceSummarySchema in tools/agent-playground/src/lib/queries/workspace-queries.ts (the daemon already returned it on the list endpoint, just untyped) and wraps the dropdown entry in {#if !isCanonical}. The isCanonical derived treats an unresolved workspace list as canonical so the destructive entry stays hidden during the loading window instead of briefly appearing on the personal workspace; the daemon already 403s the deletion attempt, but the menu used to show the entry anyway and clicking it produced a generic failure toast. (#312)
<ChatInput> is wrapped in {#key chatId} so it remounts on chat switch.ChatInput owned its own value / images state and only cleared on submit, but switching chats reuses the UserChat instance (same route, different chatId), so the unsent draft and attachments leaked from the old chat into the new one. The {#key chatId} shape forces Svelte to remount the child on every chat switch — resets all seven pieces of local $state uniformly without lifting state, adding a reset $effect/prop, or coupling the child to parent concerns; matches the {#key}-on-id pattern already used in activity-view, the workspace sidebar, and the chat-page workspaceId wrapper. (#287, fixes #222)
Dropped image previews lift out of chat-input into a single $bindableinputImages prop. Drag-and-drop on the chat surface used to route dropped files into a separate pendingImages bucket that rendered as a .pending-images-bar at the top of the chat, while the attach button used chat-input's local images state and rendered next to the textarea — same data, two state buckets, two render locations. Lifted images out of chat-input.svelte into a $bindable() prop, bound from user-chat.svelte as inputImages, and the chat-surface drop handler now writes into the same bucket. Removes the duplicate top strip and the parallel state; inputImages also clears on chat switch — previously, dropped-then-switched images leaked into the next chat as a side effect of the duplication. (#294)
Chat list visual refresh — blue * unread marker, rounded active-row pill, hover-reveal trash, pill Load more.chat-list-panel.svelte (+199/-93): unread chats are marked with a blue asterisk instead of a pulsing dot, the selected row is a rounded pill matching the left nav, hovering a row cross-fades the timestamp into a trash (delete) icon, Load more is a pill button pinned to the bottom of the list, and Web-sourced chats drop the redundant Web source badge (only Slack / Discord / Telegram / WhatsApp still show one). (#313)
Markdown anchors render with target="_blank" rel="noopener noreferrer" across every renderer call site.packages/ui/src/lib/markdown/markdown.ts's link() renderer now emits the pair on every non-anchor link (href.startsWith("#") stays same-tab so TOC links work); markdownToHTMLSafe allowlists target via DOMPurify.sanitize(..., { ADD_ATTR: ["target"] }) so DOMPurify 2.x stops stripping it. The previous renderer emitted <a target="_blank"> but the bare DOMPurify.sanitize(...) call dropped it, so every chat link reached the DOM without a target and navigated the host tab — booting users out of the chat. Reapplied after the revert of #278. (#284, fixes #263)
Nine sibling sanitize call sites migrated from bare DOMPurify.sanitize(markdownToHTML(...)) to markdownToHTMLSafe. MCP detail page (mcp-server-detail.svelte), execution stream, output tabs, connect-service in chat, the two skill viewer routes (skills/[namespace]/[name]/+page.svelte and […path]/+page.svelte), artifacts/[id]/markdown/+page.svelte, and discover/[[slug]]/+page.svelte all dropped their per-file DOMPurify + browser imports and the browser ? sanitize(...) : raw(...) gate in favor of the SSR-safe shared helper, so links in skill READMEs, MCP READMEs, and OAuth setup instructions now also open in a new tab. routes/platform/[workspaceId]/jobs/[jobName]/+page.svelte was a latent XSS surface — the drawer description was raw {@html markdownToHTML(...)} with no sanitisation at all — now routed through the helper. (#284)
Chat-stream rendering perf: lazy tool-call drawers, memoised formatRawOutput, deferred extractLiftedArtifactIds, 80ms→120ms markdown throttle. Five tweaks on the streaming hot path, none of which change rendered output. (1) Closed <details> were still rendering their JSON-highlighted input/output content — Shiki ran for every tool's payload on every chunk even when the user never opened the drawer. bind:open + {#if open} in tool-call-card.svelte skips that entirely until the user expands a drawer. (2) New tools/agent-playground/src/lib/components/chat/format-raw-output.ts caches formatRawOutput results in a bounded-FIFO WeakMap (keyed on the output reference; AI SDK v6 already keeps part.input / part.output ref-stable within a state) + a 64-entry string-keyed LRU fallback for unstringifiable / parse-fallback inputs; delegate-tool-card.svelte switches to the shared helper. (3) extractLiftedArtifactIds defers until output-available — the recursive regex walk can't find anything during input-streaming anyway. (4) Markdown throttle in markdown-body.svelte bumped 80ms → 120ms, cutting marked.parse + DOMPurify rate without crossing the perceptual threshold. Verified via Chrome perfetto on a 50-row email-list reproducer: active CPU 9.1% → 2.6%, max blocking task 39ms → 11.5ms (under one 60Hz frame), zero >16ms tasks, Shiki out of the top-15 hot frames. (#301)
/api/export/:wsId/:chatId Hono route replaces the SvelteKit +server.ts orchestrator — no HTML viewer in the zip.adapter-static strips every +server.ts / +page.server.ts at build, so the compiled binary fell through to the SPA fallback and the frontend died on Unexpected token '<' when it tried to parse index.html as a ZIP (dev was only working because Vite ran the SvelteKit handlers in-process). The new Hono route lives at tools/agent-playground/src/lib/server/routes/export.ts and is mounted by static-server.ts so it survives the static build; the zip now contains only chat.json (userId stripped at the boundary, home paths scrubbed) + assets/artifacts/{id}/{slug} entries — recipients open chat.json in a viewer of their choice. The whole routes/platform/[workspaceId]/chat/[chatId]/export/preview/* directory, +server.ts, +page.server.ts, limits.ts, preview.test.ts, and server.test.ts are deleted (-2255 LOC across the PR). Hono routes forward the caller's cookie + authorization headers to every daemon fetch. The whole pipeline is raced against a 60s budget via setTimeout + AbortController + AbortSignal.any — server-side timeout → 504, client-closed-tab abort → raw new Response(null, { status: 499 }). (#334)
getExportContext plumbing ripped out across ArtifactCard, ToolCallCard, DelegateToolCard, ChatMessageList. With the HTML viewer gone, the ExportContext Svelte context, all isExport = getExportContext() !== undefined gates, the test stubs (artifact-card-export-parent.svelte, tool-call-card-export-parent.svelte), and the export-mode tests (copy-buttons-export.test.ts, artifact-card.test.ts's export-mode cases) are deleted; /export/preview route + chromeless +layout.svelte matchers are gone. The components revert to a single live-UI rendering mode. (#334)
unsafeArtifactIdPathSegment validation runs before URL construction, not just before ZIP entry creation.encodeURIComponent("..") returns .. unchanged (dots are unreserved per RFC 3986) and new URL(".../..//content") then collapses the dot-segment into a different daemon endpoint — a URL-level traversal the slug helper alone wouldn't catch. The Hono export route now skips unsafe artifact ids before URL construction and reads artifact bytes serially so the per-artifact (MAX_ARTIFACT_BYTES, 25 MB) and aggregate (MAX_TOTAL_ARTIFACT_BYTES, 250 MB) ceilings are real rather than aspirational; oversized artifacts log a [chat-export] warn and drop the entry instead of failing the whole export. (#334)
getAtlasDaemonUrl upgrades explicit FRIDAYD_URL=http://… to https:// when TLS env is set. Stale URL files no longer misroute when an operator has run setup-tls.sh between sessions. The deno task atlas task now exec's scripts/run-atlasd.sh, a wrapper that extracts only the vars consumed at module-load time (DENO_CERT for Deno's RootCertStore, FRIDAY_TLS_CERT / _KEY for getAtlasDaemonUrl's frozen baseUrl in @atlas/client/v2) and exec's deno — everything else (FRIDAY_KEY, ANTHROPIC_API_KEY, OAuth tokens) flows through atlas-cli's own dotenv.load() in start.tsx so the same .env isn't double-parsed with two different overwrite semantics. Mirrors the launcher's commonServiceEnv pattern: one read, at the right lifecycle point. (#243)
Skill curl examples route through $FRIDAYD_URL with an optional --cacert. Skills with hardcoded http://localhost:8080 curl examples were teaching LLMs to fire cleartext at a TLS listener — the daemon auto-upgrades to https://localhost:8080 when FRIDAY_TLS_CERT is set, so the model would see "Response does not match HTTP/1.1 protocol" with no hint that it's a scheme mismatch. friday-cli, workspace-api, and writing-friday-python-agents SKILL.md + references switched every curl example to a copy-paste preamble that sources ${FRIDAY_HOME:-$HOME/.friday/local}/.env first (matches the launcher's production write location) then falls back to the dev ~/.atlas/.env, then defines friday_curl() { curl ${FRIDAY_TLS_CA:+--cacert "$FRIDAY_TLS_CA"} "$@"; } — empty TLS expansion under plain HTTP, --cacert added exactly when needed. New tools/qa/live-daemon/promptfoo/skill-tls-awareness-* promptfoo suite (852 lines of scenario code + 176 lines of tests) wires 8 static + 4 accept-control + 3 negative-control fixtures + 3 behavioral checks through promptfoo — 37/37 pass with the behavioral arm enabled. (#308)
{{platform_url}} interpolation default + agentsServerUrl fallback + scripts/clean.ts probe URL now derive from getAtlasDaemonUrl().packages/workspace/src/variable-interpolation.ts no longer hardcodes a cleartext default — agents interpolating the var on a TLS-bound daemon no longer hit cleartext. packages/workspace/src/runtime.ts::agentsServerUrl follows the same fallback. The biggest hazard fix: scripts/clean.ts's health-probe URL now derives from getAtlasDaemonUrl() — without this, clean saw a TLS daemon as "down" and proceeded to delete data while it was serving traffic. apps/atlas-cli/src/commands/daemon/status.tsx no longer claims "not running on port 8080" when the failure was a TLS-cert verification error against a daemon on a different scheme/port. (#308)
Link callback URLs travel through the browser-trusted playground origin via X-Forwarded- + manual redirects.* Two latent bugs in the playground proxy chain broke Google OAuth end-to-end after the s2s/browser TLS split: (1) tools/agent-playground/src/lib/server/proxy.ts called fetch() with the default redirect: "follow", so when Link returned 302 → accounts.google.com the SSR proxy walked the redirect itself and rendered Google's sign-in HTML under localhost:5200 (CSP base-uri / gstatic.com CORS → "Next" dead) — fixed with redirect: "manual" so 3xx + Location reach the browser. (2) apps/atlasd/routes/link.ts unconditionally overwrote incoming X-Forwarded-* with the daemon's own host, and the playground proxy didn't set them at all, so Link generated callback URLs pointing at https://localhost:8080 (s2s cert) → NET::ERR_CERT_AUTHORITY_INVALID. Playground now sets X-Forwarded-Host / Proto / Prefix (via Headers.set for explicit override semantics, not relying on Hono preprocessRequestInit ordering); the daemon's Link proxy preserves the incoming values and appends /api/link to the prefix (after stripping any trailing slash so /api/daemon/ doesn't produce /api/daemon//api/link) so Link emits https://localhost:5200/api/daemon/api/link/v1/callback/<provider>. (#315)
Playground proxy core shared between the SvelteKit dev hook and the compiled binary. The shipped playground binary's proxy in tools/agent-playground/static-server.ts had its own near-duplicate buildProxy — the OAuth fix only landed in the SvelteKit dev hook would have left end users with ERR_CERT_AUTHORITY_INVALID on the callback step and a dead "Next" button on the consent screen. Extracted the proxy body into a single executeProxyFetch helper in proxy.ts; both wrappers — buildProxyHandler (SvelteKit) and the newly-extracted buildHonoProxy (Hono / compiled binary) — now delegate to it. Deletes ~120 lines of duplicated code in static-server.ts. New proxy.hono.test.ts (58 lines) pins the Hono wrapper's behavior so the drift can't recur silently. (#315)
Daemon writes <home>/nats/url on every connect path, not just own-spawn. When the Studio launcher pre-spawned nats-server and pushed FRIDAY_NATS_URL into the daemon's env, NatsManager.start() previously returned early without writing the rendezvous file — a user running atlas migrate (or any CLI) from a separate terminal had no inherited FRIDAY_NATS_URL, no URL file to read, and fell through to their own pickPort() + spawnNatsServer, crashing with "store directory in use" on the JetStream file lock. writeBrokerUrlFile is now a single chokepoint that runs after the connect succeeds regardless of which path got us there (env URL, cached URL alive, or own spawn); the daemon becomes the sole canonical writer of <home>/nats/url, CLI ephemeral brokers and the launcher never touch it, and stop() deletes the file in all cases. Monitor log uses 127.0.0.1 (matches the host pinned in writeServerConfig) instead of localhost. (#296)
Webhook-tunnel passes --origin-ca-pool to cloudflared when the local origin speaks HTTPS. Without it cloudflared's system trust store has no entry for the private s2s CA, the default cert verify against the webhook-tunnel listener fails, and the public edge returns 502 to every inbound webhook. New Options.OriginCA field on tools/webhook-tunnel/tunnel/manager.go carries the FRIDAY_TLS_CA path; buildArgs() appends --origin-ca-pool <path> only when TLS && OriginCA != "" — preserves the mutual-auth posture of the rest of the mesh instead of disabling verify on that hop. startTunnel warns when FRIDAY_TLS_CERT / KEY are set but FRIDAY_TLS_CA is not. Config field renamed AtlasdCA → FridayCA: the bundle is the s2s CA for the whole mesh, used both by the forwarder (verify atlasd's leaf) and by cloudflared (verify our own listener's leaf). New manager_test.go (59 lines) pins the cloudflared command line for each (token, tls, ca) shape so the original --origin-ca-pool omission can't ship green again. (#327)
dev:playground propagates FRIDAY_HOME=$HOME/.atlas to the link + playground + webhook-tunnel children. The atlas:dev task set FRIDAY_HOME=$HOME/.atlas, but the sibling link, playground, and webhook-tunnel children spawned by dev:playground inherited only the parent shell's environment without it — link silently fell back to ~/.friday/local while the daemon used ~/.atlas, leaving MCP registry entries pointing at Link providers that lived under a different FRIDAY_HOME. One-line deno.json hoist of the prefix onto the dev:playground task itself so all four children agree on the home directory. (#341)
PUT /api/config/env hot-reloads .env into the running daemon — no restart needed for new API keys. Before, the Settings UI wrote keys to .env on disk but process.env (loaded once by Deno --env-file at startup) didn't see them, the LLM registry's lazy singleton memoized stale creds, and model-catalog.ts's 1h TTL cache served the empty Groq entry until restart. The PUT handler in apps/atlasd/routes/config.ts now diffs the prior on-disk state against the new payload, Object.assigns adds/updates into process.env, deletes removed keys, then calls resetRegistry() and invalidateCatalog() so the next catalog fetch re-evaluates resolveCredential against the live env, flips credentialConfigured: true, and the picker shows models — all in-process. New hot-reload tests in config.test.ts lock the three load-bearing behaviors (PUT adds, PUT-omits deletes, denylist disk/runtime divergence). (#290)
PlatformModels.get(role) re-resolves on every call.createPlatformModels in packages/llm/src/platform-models.ts previously eagerly resolved each role at construction into a Map<PlatformRole, LanguageModelV3>, leaving model instances bound to the registry that existed at startup — resetRegistry() couldn't reach them, so planner/classifier/conversational calls kept using stale credentials even after the catalog flipped to "live". Now boot-time validation still happens (every role resolves eagerly and bad config throws an aggregated PlatformModelsConfigError — fail-fast on bad friday.yml), but the per-call lookup re-runs resolveRole against the live process.env. A regression test in platform-models.test.ts mutates process.env between two get() calls and asserts the resolution flips. (#290)
invalidateCatalog() in packages/llm/src/model-catalog.ts carries a generation counter so a fetch already past Promise.allSettled can't commit stale process.env-bound results. Renamed from resetCatalogCacheForTests because PUT /env now calls it in production. Without the counter, a getCatalog() fetch in flight at the moment env changed would still write its result into cache via the trailing .then, wedging the catalog with a 1h-TTL stale credential view. Each fetch snapshots generation before awaiting and the .then only commits when the counter is unchanged; invalidateCatalog bumps it so any mid-flight commit is silently dropped. (#290)
Agent-spawn switches to uv run --directory <agent_dir> for project-style Python agents.apps/atlasd/src/agent-spawn.ts now resolve()s the registered agentPath to absolute (RegisterRequestSchema only validates z.string().min(1), so a relative path would otherwise key pyproject detection off process.cwd() and pass --directory . to uv against the wrong tree), then existsSync-checks for pyproject.toml in the same directory. When present, the spawn uses --directory so uv reads the project's declared dependencies and lockfile; when absent, the single-file path (uv run --with friday-agent-sdk==<v>) is preserved. The previous single-file path injected only the SDK and silently dropped everything else the agent imported. The routing is purely a property of the on-disk layout — no schema or registration shape change. (#314)
ProcessAgentExecutor.execute honors jobs.<name>.config.timeout for the subprocess kill ceiling.WorkspaceRuntime now spreads buildExecutorTimeoutOption(opts.jobTimeoutMs) into the executor options call at packages/workspace/src/runtime.ts line 2967. Before, the executor silently fell back to DEFAULT_TIMEOUT_MS = 180_000 (3 minutes) regardless of what config.timeout said — config.timeout: "2h" only affected elicitation TTL via wrapPlatformToolsWithScope, the agent itself died at 3 min anyway. A long-running user agent (e.g. the reindex agent embedding 150K+ rows) now honors the job's wall-clock budget; parseJobTimeoutMs rejects <= 0 with a warn because nats.js's nc.request({ timeout }) treats timeout: 0 as expire-immediately. (#314)
Workspace section renders covered signals as NAME (use tool: JOB) instead of NAME (POST /webhooks/...). When a workspace declares an HTTP signal that's covered by a job, formatWorkspaceSection in packages/system/agents/workspace-chat/workspace-chat.agent.ts now builds a signalToJob: Map<string, string> from config.jobs[*].triggers[].signal and prefers the job-tool branch over the legacy describeSignalTrigger HTTP-path branch. Closes the foot-gun where the model web_fetched localhost / webhook URLs instead of calling the registered job tool that createJobTools already exposed. (#314)
Settings page drops the "Restart the daemon to apply" warn-banner and successFlash flash for .env saves.tools/agent-playground/src/routes/settings/+page.svelte removes pendingRestartKeys state, the confirmPendingAgainstCatalog helper that maintained it, and both successFlash assignments in saveEnv / handleSaveApiKey. The banner's claim is now flat-out wrong (no restart needed thanks to the daemon-side hot-reload) and the flash adds noise to a flow that's instant by design. loadCatalog still runs after save so provider-connected chips refresh; the successFlash state stays for the models-save path because friday.yml still requires a restart. (#290)
writing-friday-python-agents system skill re-vendored from friday-agent-sdk tag v0.1.9.packages/system/skills/writing-friday-python-agents/SKILL.md (-256 lines net across SKILL.md + references) picks up the agent-sdk#22 cleanup: ctx.input appears in references/api.md with full method signatures; the SDK no longer teaches daemon HTTP registration calls; the dataclass→JSON Schema overclaim for output_schema / input_schema is gone (those kwargs are documented as no-ops until the bridge serializes them); NATS_URL is the correct env var name; a Tier 6 example covers inputFrom consumption. (#337)
Add credential → link in the workspace MCP credential picker points at /mcp/{serverId}/connections.tools/agent-playground/src/lib/components/mcp/mcp-credential-picker.svelte updates the empty-state link from /mcp/{serverId} to the dedicated connections sub-route, matching the sibling Manage → link target rather than landing the user on overview. (#342)
Fixed
Image drag-and-drop now works in Safari. Safari is strict about the HTML5 drag-and-drop spec: an element only becomes a valid drop target when preventDefault() is called on BOTH dragenter AND dragover. The chat drop zones only handled dragover, so Chrome (which is lenient) accepted drops while Safari fell through to its default action and opened the dropped image in a new tab. Added dragenter handlers on chat-input.svelte and the outer user-chat.svelte container, and set dataTransfer.dropEffect = "copy" so Safari keeps treating the area as a copy-drop target throughout the drag. Same fix applied to the other playground file drop zones: artifact-upload.svelte (HITL file attachments), skill-loader.svelte (skill bundle upload), and workspace-loader.svelte (workspace YAML upload). model-chain.svelte is internal item reordering (effectAllowed="move", no file data), so the Safari fallback doesn't apply there and it's left alone. (#291)
Cross-workspace IDOR on data-artifact-attached parts closed. The chat-submit path trusted the client-supplied artifactIds array on data-artifact-attached parts and fetched each artifact's bytes with no scope check — any authenticated user could hand-craft a part referencing another tenant's artifact id (guessed or observed) and the daemon would inline that artifact's content into their chat history and ship it to the agent on the next turn. inlineAttachedArtifacts now threads this.workspaceId from AtlasWebAdapter and skips any artifact whose meta.data.workspaceId is set and disagrees with the chat's workspace; legacy / global artifacts (no workspaceId field) keep working to match the existing artifact REST routes. Skipped attempts log atlas_web_adapter_attached_artifact_cross_workspace so operators can spot probing. Three JetStream test-broker cases pin the gate (same-workspace inlined, cross-workspace refused with a warn, legacy no-workspace passes). (#292)
Export preview no longer crashes during SSR on chats containing ArtifactCards. A chat exported via /platform/<ws>/chat/<id>/export/preview crashed during SSR with "No QueryClient was found in Svelte context" the moment any message surfaced an artifact card. ArtifactCard always called createQuery() and relied on a null query id (→ skipToken) to "disable" the query in export mode, but TanStack's skipToken disables the query, not the hook — createQuery() still calls useQueryClient() under the hood and throws if no provider is mounted, and the chromeless export-preview layout never mounts a QueryClientProvider. Both createQuery hooks in artifact-card.svelte are now skipped entirely when exportCtx !== undefined, and every dependent $derived is updated to use optional chaining. The prefetched artifact map drives the render in export mode; live UI is byte-identical. (#310 — note: superseded later in the cycle by #334, which removes the export-preview path entirely)
Dark-mode dark rectangle behind every step icon on the Runs page.agent-block-card.svelte's .icon container is a vertical strip that masks the timeline border behind the spinner / status dot; it was filled with --color-surface-1 (hsl(228 2% 7%)), but the surrounding page uses --surface (rgb 27 28 29). In dark mode the icon-fill is ~4% darker than the page, so the mask showed up as a black rectangle behind every step. Switched the icon fill to --surface across running / completed / failed / pending states, and also fixed the same --color-surface-1 mask on step-block/header.svelte. (#311)
Running-step spinner actually spins.agent-block-card.svelte referenced animation: spin 2s linear infinite on .icon.running but never defined @keyframes spin. Svelte scopes styles per component, so the keyframes defined in other components didn't reach this one and the spinner sat motionless. Added the keyframes locally and slowed the rotation from 2s to 4s per turn. (#311)
Workspace-sidebar footer fade band passes clicks through to the workspace row beneath. The sticky footer overlaying the bottom of the scrollable workspace list was swallowing clicks on the workspace row underneath. shared/sidebar.svelte now sets pointer-events: none on the footer wrapper and pointer-events: auto only on the Docs <a> pill — status pill, dead space, and fade pass clicks through; only the Docs link itself stays interactive. (#313)
Chat history no longer wipes after a failed tool call. When the model emitted a call for an unbound tool, the AI SDK persisted the failed call as a tool-* part with state: "output-error" and the model's args parked in rawInput, leaving input undefined. The SDK's own UIMessage schema for that state requires input, so every subsequent history load threw AI_TypeValidationError, the chat route 500'd, and the workspace-chat agent's history fetch fell back to an empty array — the model answered with no context and every turn came back with the default "Hey, what can I help you with?" The poisoned shape lived in the persisted chat history forever, so once a thread tripped over it, the only recovery was abandoning the chat. validateAtlasUIMessages in packages/agent-sdk/src/messages.ts now backfills input from rawInput on output-error tool parts before validation — same fallback the SDK does on its own read-side conversion (convert-to-model-messages.ts). appendMessage in packages/core/src/chat/jetstream-backend.ts persists the validator output so new writes converge to a clean shape; legacy chats are repaired on read. (#309)
setup-tls.sh scopes mkcert to the system + nss trust stores so a half-installed JDK doesn't abort the script. A half-installed JDK on $PATH (cacerts missing) makes mkcert -install exit non-zero even after the system-trust install succeeds — under set -e that aborted the script before any s2s certs or .env entries got written, so the daemon and tunnel came up with no TLS material at all. export TRUST_STORES=system,nss explicitly tells mkcert which stores Friday cares about, and unset JAVA_HOME script-wide blocks the keystore probe (TRUST_STORES alone only scopes the install set — mkcert still probes Java's keystore via keytool -list on every invocation whenever JAVA_HOME is set). Friday doesn't run on the JVM, so the CA in Java's cacerts was never needed. (#300)
setup-tls.sh pins SHA-256 explicitly on both the CA self-sign and the leaf cert.openssl x509 -req defaults to SHA-1 on macOS's bundled LibreSSL 3.x; Node 18+ then rejects the resulting leaf with "CA signature digest algorithm too weak", and every SSR proxy fetch from the playground to the daemon came back as a 502. Coworkers with Homebrew's OpenSSL 3.x earlier on PATH never hit this — that build defaults to SHA-256. Both openssl req -new -x509 (CA) and openssl x509 -req (leaf) now carry an explicit -sha256. (LibreSSL's req -new -x509 already defaulted to SHA-256, which is why the CA worked before this fix and only the leaf was broken.) (#306)
HTTP fetch body undrained on !response.ok was the daemon's highest-impact FD leak. Every code path that dropped a Response without consuming the body held the socket in hyper's "borrowed" state; under concurrency the pool then had to open new sockets for new requests because the old ones couldn't be reused. Microbench measured BROKEN peak=341 TCP vs FIXED peak=211 TCP across 2k requests @ concurrency 64 with 1 MiB bodies — a leak of 130 sockets (38%) per burst. Real-world trigger: a daemon hitting "Too many open files (os error 24)" after ~49h uptime on a localhost fetch to link, breaking a daily report job and requiring an installer reinstall to recover. Sub-MSS bodies on quiet systems do recycle, so the leak only manifests under bodies larger than the initial congestion window, sustained concurrency, or GC pressure delaying Response finalization. (#340)
agents/register subprocess lifecycle: missing on("exit") + unconsumed stdout was leaking 3 pipe FDs per spawn.apps/atlasd/routes/agents/register.ts spawned the agent with stdio:"pipe" and SIGTERM as fire-and-forget — Node held the ChildProcess (with its 3 pipe FDs) until GC visited it, and stdout was never consumed so any agent that printed (sdk debug, accidental print) blocked on write past ~64 KiB. Now spawned with stdio:["ignore","ignore","pipe"], an on("exit") whose promise is awaited in finally, and SIGTERM → SIGKILL escalation after a 2s grace — mirrors the lifecycle ProcessAgentExecutor already had. Bench: BROKEN peak 17 pipe FDs vs FIXED 12 across 200 spawns. (#340)
createHttpFetchHandler's 5 MiB size-cap loop leaked the ReadableStreamReader when read() itself threw.packages/workspace/src/agent-executor-utils.ts had a cancel() only on the size-overflow branch — if reader.read() rejected (server hangup mid-body, client abort), the reader stayed locked and hyper couldn't recycle the socket. Wrapped the read loop in try/finally with reader.cancel() (which releases the lock AND tells hyper "we're done with the socket" — releaseLock() alone leaves unread bytes pinned). Defense-in-depth for the memory-pressure case where v8 GC stalls past hyper's idle reclaim window; no regression risk because the cancel is a no-op on the clean-EOF path. (#340)
MCP child stderr accumulator in packages/mcp/src/process-registry.ts is now bounded at the readiness gate. The on("data") handler appended every stderr byte to a string buffer for the "spawn exited before ready" diagnostic envelope and never removed it once readiness was reached — a chatty uvicorn child would accumulate forever. Bench: BROKEN held 31.3 MiB of stderr after just 5s of normal output / ΔRSS +59 MiB; FIXED holds 0.81 MiB (the pre-ready bytes that are actually load-bearing) / ΔRSS +34 MiB (-97% accumulator, -42% RSS). Implementation keeps a single named handler attached for the child's whole lifetime so the kernel pipe buffer never back-pressures the child (which would hang chatty MCPs after ~64 KiB of post-readiness output if the handler were removed), but flips an accumulating flag to false at the readiness gate — bytes after that point are drained and dropped on the floor. Same file's readiness-polling fetch (deps.fetch(readyUrl, { method: "GET" })) now drains the body unconditionally on every poll iteration immediately after the fetch resolves, before any branching. (#340)
Doctor preflight test no longer races on a pre-flush status check. The assertion at apps/atlasd/routes/mcp-registry.test.ts:710 checked the persisted entry was still setting_up immediately after the HTTP response — but all doctor mocks (fetchReadme, runDoctor, adapter.update) resolve synchronously, so the background task drains through awaiting_confirm during the res.json() await. The HTTP response body is already validated as status:"setting_up" by PreflightSettingUpSchema.parse, making the persistence check redundant and racy. Removed; the post-flush assertions still cover the full state transition. (#333)
Launcher raises RLIMIT_NOFILE to 65536 (with a 10240 fallback tier) at boot before any child is spawned. The macOS default soft RLIMIT_NOFILE for processes spawned via launchd / LaunchServices / open is 256, and even when launched from a kernel-aware context the inherited cap on modern macOS tops out at 10240 — plenty for the launcher but not for the supervised friday daemon plus its 5 sibling services holding a NATS connection, cron timers, per-workspace MCP children, and HTTP keep-alives across multi-day uptime. Real-world trigger: a Snowflake MCP subprocess spawn returning EMFILE after ~49h uptime, with the analyst agent silently falling back on chat memory and emailing a stale report. New tools/friday-launcher/rlimit_unix.go (75 lines) calls syscall.Setrlimit once at launcher boot — every supervised process inherits the higher cap via fork(), so one syscall covers the whole tree without plumbing the value through each spawn site. Falls back to 10240 if the kernel rejects 65536 (older macOS clamps unprivileged setrlimit at OPEN_MAX), never goes above the hard limit, and is a no-op when the inherited soft is already adequate. rlimit_windows.go is a no-op stub — Windows uses HANDLEs, not POSIX FDs. (#332)
Workspace-chat reflects its own config changes mid-session. With block 2 already wired to invalidateBlock2, the chat agent's 5-minute Block 2 fetch cache is now dropped on the next turn after any upsert_job / upsert_signal / upsert_agent / upsert_mcp_server / publish_draft / enable_mcp_server / disable_mcp_server 200 — so a job the chat just created is callable and a signal it just changed validates against the new schema, with no 5-minute lag. The staleness a self-modifying chat saw was the chat agent's own Block 2 fetch cache, not the daemon — the daemon had fresh config the whole time. (#321)
Skills bootstrap no longer rejects three newly added skill frontmatters.packages/system/skills/bootstrap.ts#publishOne failed at daemon start on delegate-handoff/SKILL.md (description carried <available_skills> literal; the noXmlTags refinement on PublishSkillInputSchema.description rejects < / > to prevent XML injection into agent prompts), debugging-runtime-errors/SKILL.md (same refinement tripped on the <type> placeholder in an example error message), and using-elicitations/SKILL.md (pre-existing skill whose flow-scalar description contained `type: user` — colon-space inside backticks made the YAML parser see a key-after-key mapping and bail at column 77). Fixes drop the angle-bracket placeholders (rephrase as available-skills index, drop the <type> token entirely) and switch the elicitations description to a block scalar (description: |) so the inline colon inside backticks reads as plain prose. Adds a regression assertion to skill-catalog-and-directives.ts scanning every new skill's frontmatter for < / > and failing the eval if either is present. (#297)
Skill cross-references rewritten to bare @friday/X form. Every [[contracts/X]] / [[author/X]] / [[runtime/X]] / [[debug/X]] link in the 7 new skills was misleading because the directory layout is flat — no subdirs exist. Two prompts (workspace-chat/prompt.txt lines 118 and 341) carried @friday/contracts/agent-action-handshake references the previous cleanup pass missed; both corrected to bare @friday/agent-action-handshake. A new skill-catalog-no-broken-audience-refs eval case recursively walks packages/system/skills/ and packages/system/agents/ for any @friday/(contracts|author|runtime|debug)/ substring and reports hits as failures. (#297)
exportGlobalSkills rewired against SkillStorageAdapter instead of the empty post-migration skills.db. The settings UI's "Include your skills library" toggle silently produced empty exports after the May 3 migration moved user-published skills from SQLite to JetStream KV + Object Store; the old export code still read ~/.friday/local/skills.db, which post-migration is empty. New bundle layout: manifest.yml (schemaVersion: 1, kind: global-skills, source: { filename: skills.jsonl, skillCount, sha256 over JSONL bytes }), skills.jsonl (one SkillRow per non-system, non-draft skill, archive bytes excluded), archives/<skillId>__<version>.tar.gz per skill carrying an archive. System-skill filter mirrors the migration's behavior (skip everything with createdBy === SYSTEM_USER_ID, exported as the single source of truth from packages/skills/src/storage.ts). Drops draft skills (name === null) — they can't round-trip through publish. (#242)
Skills→JetStream migration now skips drafts entirely.m_20260503_110100_skills_to_jetstream.ts was calling adapter.create() on draft rows (name === null), which mints a fresh skillId / ulid / id — so the legacy SQLite id became unreachable from any pre-migration reference and the row was invisible in every UI list (which filters name === null). The docstring claimed it left "a shell so getById() still resolves," which was false. Skipped entirely now; drafts have no published-version history worth replaying and create() can't preserve their identity. (#242)
System skills with bundled references/ / assets/ / scripts/ directories bootstrap correctly inside compiled installer builds.packages/system/skills/bootstrap.ts previously called packSkillArchive(dir) directly against the VFS path; tar.create() internally calls node:fs.open() to get numeric OS fds for each source file, and the Deno compiled binary embeds skill dirs in a virtual filesystem that cannot provide real OS fds, so tar threw "Failed to get OS file descriptor from file". The new copySkillDirToReal(srcDir) recursively copies a VFS tree to a fresh makeTempDir({ prefix: "atlas-skill-copy-" }) using only readdir / readFile / writeFile (all VFS-safe), then packSkillArchive runs on the real copy; the temp dir is cleaned up in a finally block via rm({ recursive, force }) with a logger.debug on cleanup failure. Affected skills: authoring-skills, friday-cli, workspace-api, writing-friday-python-agents, writing-workspace-jobs, writing-workspace-signals. The publish-shortcut path also changed: disabled skills now fall through to re-publish on hash match instead of calling setDisabled, because a prior boot may have tombstoned a skill after a failed archive pack (archive=null in DB) — re-publishing heals the null archive atomically rather than just restoring visibility. (#331)
Live-daemon promptfoo harness boots a daemon reliably. Four latent breakages, each masking the next, left first-principles / retrieval-gated / table-outcomes / tool-suite-management unable to boot a daemon or register workspaces: FRIDAY_TLS_CERT / _KEY from ~/.atlas/.env rode into the spawned daemon and made it bind https:// while the harness probed http:// (now cleared in the spawned-daemon env override); the atlas deno task inlined FRIDAY_HOME=$HOME/.atlas, clobbering the harness's temp home (now spawns deno run directly instead of via the task wrapper); /var vs /private/var mismatch in the home-isolation check (now realPath-resolves the temp FRIDAY_HOME so it matches the daemon's symlink-resolved workspace paths); workspaces materialized outside FRIDAY_HOME were masked by the workspace manager (new makeFixtureDir() helper materializes fixtures under <home>/qa-fixtures/). All 16 promptfoo eval configs now pass. (#297)
PUT /api/config/env skips a 15-key denylist when mutating process.env.HOT_RELOAD_DENYLIST covers PATH, HOME, NODE_OPTIONS, LD_*, DYLD_*, FRIDAY_HOME, FRIDAY_ENV, USER, SHELL, plus the four subprocess-spawn vars FRIDAY_UV_PATH, FRIDAY_AGENT_SDK_VERSION, FRIDAY_AGENT_PYTHON, FRIDAY_CLAUDE_PATH (read per-spawn by agent-spawn.ts and the claude-code provider — mutating them on the running daemon would partition reality between already-spawned and future-spawned agents). Disk still receives whatever the user wrote; only the runtime mutation is gated. The set is exported so apps/atlasd/routes/config.test.ts can iterate it via test.each([...HOT_RELOAD_DENYLIST]) for automatic coverage of any future entries. (#290)
Long credential labels no longer push the Replace/Remove actions cell off the right edge of the MCP Connections page.tools/agent-playground/src/lib/components/mcp/mcp-credentials-list.svelte zeros the min-inline-size on both the row's .cred-info flex container and the .cred-label itself, and sets overflow-wrap: anywhere; white-space: normal on the label so unbreakable hex tokens wrap on any character. The actions column now resolves within the container width instead of being clipped behind the page edge. (#342)
MCP tools probe Retry button cancels the in-flight HTTP request instead of stacking probes on the daemon.fetchToolsProbe in tools/agent-playground/src/lib/queries/mcp-queries.ts now accepts an optional AbortSignal and threads it to the Hono client as { init: { signal } } (Hono v4 $get(args, opts) takes opts.init: RequestInit, not opts.signal — the signal goes one level deeper and Hono spreads opts.init into the fetch RequestInit). TanStack v5's default cancelRefetch: true aborts the observer's AbortController, but the underlying network request only actually aborts if the queryFn propagates the signal — without this, each Retry click stacked a fresh probe while the previous one kept running on the wire, and after 5–10 clicks the daemon ran out of file descriptors / subprocess slots and silently stopped accepting new connections (the SPA appeared to lock up because route data fetches hung against the wedged daemon). The two Retry buttons in mcp-tools-section.svelte and mcp-tool-invoker.svelte also gate on disabled={isFetching} as defense-in-depth. (#343)
Configuration
FRIDAY_TLS_CERT / FRIDAY_TLS_KEY / FRIDAY_TLS_CA / DENO_CERT / NODE_EXTRA_CA_CERTS opt the local mesh into TLS. When all five are set in <friday-home>/.env, the daemon (AtlasDaemonOptions.tlsCert / tlsKey), the playground (vite.config.ts + static-server.ts via tls-paths.ts), and the webhook-tunnel (config.go) all bind https; CORS origin and self-loop URLs follow the bound scheme. DENO_CERT must land before Deno's RootCertStore initializes — the scripts/run-atlasd.sh wrapper extracts the module-load-time vars and execs deno to satisfy that contract. Without certs everything stays on plain HTTP — opt in with bash scripts/setup-tls.sh. ENVIRONMENTS.md documents the full set + the .env-sourcing pattern. (#243, #308)
PLAYGROUND_OAUTH_SHIM_PORT + FRIDAY_OAUTH_SHIM_BASE env vars wire the Google OAuth callback shim. The plain-HTTP shim listener binds 127.0.0.1:<PLAYGROUND_OAUTH_SHIM_PORT> (playground port + 1 by default); FRIDAY_OAUTH_SHIM_BASE tells Link's delegated flow what to rewrite state.uri to so the Gemini CLI Cloud Function's hostname check accepts. Set automatically by the launcher; operators of dev installs don't need to touch these. (#320)
SKIP_TLS=1 / SKIP_MKCERT=1 opt out of setup-tls.sh steps.SKIP_TLS=1 in setup-dev-env.sh skips the whole TLS pip; SKIP_MKCERT=1 builds only the private s2s chain (no system-trust browser cert) for headless / CI runs. (#243)
SvelteKit catch-all proxy uses a long-lived undici Agent with 1-hour headersTimeout and bodyTimeout.tools/agent-playground/src/lib/server/proxy.ts exports longLivedDispatcher, PROXY_DISPATCHER_TIMEOUT_MS (3,600,000), and a frozen PROXY_DISPATCHER_OPTIONS = { headersTimeout, bodyTimeout } constant, then passes the dispatcher to fetch() via the non-standard dispatcher field on a local UndiciRequestInit that extends the platform's RequestInit (undici 7's RequestInit narrows body to drop ArrayBufferView). Node's bundled undici ships a 300,000ms (5-minute) default on the global fetch, so any daemon endpoint that takes more than 5 min to send response headers — chiefly POST /api/workspaces/{id}/signals/{id} for jobs like reindex where the daemon awaits the cascade before returning — would fail with UND_ERR_HEADERS_TIMEOUT. A clean reindex (~300MB CSV → 151,760 docs + 768-dim embeddings) now completes in 9 min 4s through the UI. The 502 path now reports elapsedMs in the response body and logs it via console.warn. (#314)
deno task clean pins FRIDAY_HOME=$HOME/.atlas on both clean and clean:agents. Before, the task ran with no FRIDAY_HOME, so it resolved the user-mode default (~/.friday/local) while the daemon tasks pin ~/.atlas — clean was effectively a no-op against actual dev state, leaving the JetStream registry untouched. Now matches atlas / atlas:dev / start so clean actually clears the running dev daemon's data. (#319)
Removed
mcp-test-chat.svelte and mcp-connection-test.svelte removed. The NL-message test-chat (317 lines) and connection test (241 lines) are superseded by the new mcp-tool-invoker.svelte raw tools/call form. (#323)
Standalone /platform/[workspaceId]/mcp/+page.svelte removed. The 717-line dedicated MCP page is replaced by the MCP section composed onto the new workspace settings page. (#323)
dev:playground:stable task deleted from deno.json and deno.compile.json. The stable variant invoked the daemon via deno task atlas (boots otel-bootstrap.ts directly), whereas dev:playground goes through scripts/dev-watcher.ts which loads ~/.atlas/.env and seeds DENO_CERT before Deno binds its RootCertStore. With private-CA s2s TLS in place, the stable variant's daemon process boots with only the built-in webpki roots and its own fetches to the TLS listener fail with invalid peer certificate: UnknownIssuer. dev:playground covers the local-iteration case; the compiled binary uses the installer's launch path, not these tasks. (#307)
scripts/migrate-memory.ts (309 lines) and its migrate-memory task entries are gone. A one-shot old→new memory-model migration that read the retired storage.db Deno KV path, so it can no longer do anything. (#319)
Dependencies
zod bumped from 4.3.6 to 4.4.3 (all 27 package.json sites + deno.lock). Zod 4.4 no longer treats z.unknown() or z.undefined().or(...) inside an object schema as implicitly optional — a missing key now throws expected: nonoptional, received: undefined. Two callsites adapted with explicit .optional(): args: z.unknown().optional() on ToolCallSummarySchema in packages/core/src/session/session-events.ts, and requiredConfig: z.array(z.any()).length(0).optional() (replacing z.undefined().or(...)) in apps/atlasd/routes/mcp-registry.test.ts. (#251)
svelte dev-dep bumped from 5.55.5 to 5.55.7 in apps/studio-installer. Patch-level bump; package-lock.json and deno.lock regenerated. (#328)
friday-agent-sdk bumped to 0.1.9. Picks up the system-skill source for writing-friday-python-agents (the ctx.input documentation cleanup) and the renamed daemon tool the SDK now wraps. (#337)
CI
Studio-installer macOS legs prepend $HOME/.cargo/bin to GITHUB_PATH so the dtolnay-installed Rust toolchain wins lookups. The macos-latest-large (intel) and macos-latest-xlarge (arm, regressed after image bump 20260427.0018 → 20260511.0048) runner images ship a pre-installed /usr/local/bin/cargo that is actually rustup-init and dies on Tauri's cargo metadata with unexpected argument 'metadata' found. The new "Pin PATH to fresh Rust toolchain (macOS)" step in .github/workflows/studio-installer-build.yml runs if: startsWith(matrix.platform, 'macos'), appends $HOME/.cargo/bin to GITHUB_PATH, and prints cargo --version / rustc --version for visible regressions in logs. Swatinem/rust-cache now sets cache-bin: false (per Swatinem/rust-cache#16, cache-bin: true corrupts ~/.cargo/bin on macOS by replacing the cargo proxy with rustup-init) and bumps prefix-key to v1-rust to invalidate caches saved during the broken runs so they can't re-poison the bin dir on restore. (#326, 2cdf182)