diff --git a/CHANGELOG.md b/CHANGELOG.md index b33e899..e6470ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,69 @@ All notable changes to this project are documented here. The format is based on > for the next release. Each tag push also gets a fresh entry generated by the > `softprops/action-gh-release` `generate_release_notes: true` step. +## [Unreleased] + +## [2.0.10] - 2026-06-23 + +### Added + +- **Pin context files**: a permanent, clickable context-files chip in the side + panel opens a new modal to view auto-loaded context (FRIDAY.md/AGENTS.md) and + pin project files into context; pins persist for the session. +- **Layered config** like Claude Code: `~/.friday/config.json` (user) → + `.friday/settings.json` (project, committed) → `.friday/settings.local.json` + (project-local, gitignored), deep-merged. See `docs/configuration.md`. +- **Computer-use permissions**: macOS buttons in the computer-use modal that open + the Accessibility and Screen Recording panes directly so you can click-grant. +- **Kitty keyboard protocol** enabled where supported, so chords legacy terminals + can't encode (Shift+Enter, Cmd+Enter) work in kitty/WezTerm/Ghostty/modern iTerm2. +- **tmux control center**: when tmux is available, the dashboard launches sessions/ + teams/swarms into a tiled tmux "wall" of real terminals — arrange (tiled/cols/ + rows/main), close each pane or all, and "open" a window attached to the wall to + watch everything in one view. Falls back to separate OS windows without tmux. +- **Delegated agents ask in the main view**: a subagent/team/swarm question or + permission prompt now surfaces in your current view, labeled with the agent + asking; you answer without switching and it bridges back to that agent. + +### Changed + +- **Pause keybinding** moved off `Shift+Esc` (terminals can't encode it — it + arrived as plain Esc) onto **Ctrl+Space** (single-handed, works in every + terminal), plus **Cmd+Enter** on kitty-protocol terminals. Newline stays + Shift+Enter / Option+Enter. +- **Changes panel is now a full session summary**: in a git repo it shows + everything done since the session started — committed **and** uncommitted **and** + removed files — by diffing the working tree against the commit captured at + session start (committed files no longer vanish after `git commit`). +- **Resume restores the whole session feel**: per-session model/provider/effort, + mode, the compaction summary, the git base, and an active worktree all persist + and come back on resume (previously these reset to the global default or were + lost). Resuming a session from the dashboard also opens its window in that + session's own folder. +- **Plan mode** asks before planning: for vague requests it now uses `ask_user` + with concrete options first, and `exit_plan` only carries a real implementation + plan. A trivial request (a listing, a question) is answered directly instead of + being dressed up as a "PLAN READY" gate (removed the last-message fallback). +- **Settings** uses a horizontal tab bar (the same `Tabs` the dashboard uses) for + a consistent feel; the context-files modal now has a proper search/input field. +- **Side panel**: removed the redundant "context" title; the close control is + right-aligned with its `ctrl+b` shortcut shown clearly. +- **Plans/Todos/Changes sections**: reversed the washed-out hierarchy — section + titles are now bright and bold, counts/meta stay faint; sections are spaced apart. +- **Light theme** rebuilt as a warm-paper palette (Claude-Code-style) instead of + harsh pure white, keeping the amber brand; high-contrast pushed further from dark. +- **Effort slider** redrawn as a colored cool→warm intensity bar-ramp. +- **Icon consistency**: MCP (`⧉`), skills (`◆`), tool (`⚙`) and context (`📎`) now + use distinct glyphs from a central table instead of all sharing `⚡`/`✓`. + +### Fixed + +- **Dashboard terminal windows** failing silently: the launcher now resolves the + friday executable robustly (absolute path / PATH fallback) and surfaces the real + error in a toast instead of a generic "no backend" message. +- **Changes panel went stale after a rewind/redo**: `restoreCheckpoint` and + `redoLast` now refresh the changes panel (todos/plans already refreshed). + ## [2.0.9] - 2026-06-23 ### Added diff --git a/docs/agents-and-teams.md b/docs/agents-and-teams.md index 91cb781..f80e608 100644 --- a/docs/agents-and-teams.md +++ b/docs/agents-and-teams.md @@ -36,13 +36,21 @@ them when done. The board tools are `board_post`, `board_read`, |---|---|---| | Dashboard | `/dashboard` or Ctrl+O | Sessions, Teams, and Swarm in one view. | | Console | `/console` or Ctrl+T | The live team cockpit: the shared board plus the roster. | -| Fleet | `/fleet` | One external terminal window per running agent. | - -`/fleet` needs a terminal backend: it uses tmux if you are already inside a tmux -session, otherwise Terminal.app/iTerm on macOS or a detected emulator on Linux -(wezterm, gnome-terminal, konsole, x-terminal-emulator). Windows is not -supported. If no backend is found, watch the agents in the dashboard's Swarm tab -instead. +| Fleet | `/fleet` | One real terminal per running agent — a tiled tmux wall when tmux is present, otherwise separate OS windows. | + +`/fleet` needs a terminal backend. When **tmux** is available it launches every +session/team/swarm into a tiled "wall" of real terminals you control from the +dashboard: re-arrange the panes (tiled, columns, rows, or main), close one pane +or all of them, and "open the wall" to attach a terminal and watch every pane in +one view. Without tmux it falls back to separate OS windows — Terminal.app/iTerm +on macOS or a detected emulator on Linux (wezterm, gnome-terminal, konsole, +x-terminal-emulator); Windows is not supported. If no backend is found, watch the +agents in the dashboard's Swarm tab instead. + +When a delegated agent (sub-agent, team member, or swarm worker) needs to ask a +question or request a permission, the prompt surfaces in your current view — +labeled with the agent that's asking — so you answer in place and the answer +bridges back to that agent without switching windows. ## Scheduling and isolation diff --git a/docs/configuration.md b/docs/configuration.md index 71be29a..57d4968 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -48,6 +48,30 @@ list. Most of these can also be set from inside the TUI: `/model`, `/effort`, `/theme`, `/budget`, `/mcp`. Changes made there are written back to `config.json`. +## Settings precedence (user → project → local) + +Like Claude Code, config is layered. Friday reads three files and deep-merges +them, with later layers winning (nested objects merge key-by-key; arrays and +scalars are replaced): + +1. `~/.friday/config.json` — **user** settings, apply everywhere. +2. `.friday/settings.json` — **project** settings, committed to the repo so the + whole team shares them (model, mcp, hooks, bash policy, theme…). +3. `.friday/settings.local.json` — **project-local** overrides, **gitignored**; + your personal tweaks for this checkout. + +All three use the same shape as `config.json` above. TUI changes are written to +the user file by default. Recommended `.gitignore` for a project that commits +its settings: + +```gitignore +.friday/* +!.friday/settings.json +``` + +That commits `.friday/settings.json` while keeping `settings.local.json` (and +everything else under `.friday/`) out of version control. + ## Bash allow and deny The `bash` block gates shell commands before they run. `deny` blocks a command diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 6f3f29c..0e5a9a9 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -1,6 +1,7 @@ import fs from "node:fs" +import path from "node:path" import type { McpServerConfig } from "@friday/mcp" -import { configPath, fridayDir } from "@friday/providers" +import { configPath, fridayDir, projectConfigPath, projectLocalConfigPath } from "@friday/providers" import type { Effort, ModeId } from "@friday/shared" import type { HooksConfig } from "./hooks.ts" import type { MicConfig } from "./mic.ts" @@ -45,21 +46,55 @@ export interface FridayConfig { autoCompactThreshold?: number } -export function loadConfig(): FridayConfig { +/** Read & parse one JSON config file; missing/invalid files contribute nothing. */ +function readLayer(file: string): Partial { try { - return JSON.parse(fs.readFileSync(configPath(), "utf8")) + const parsed = JSON.parse(fs.readFileSync(file, "utf8")) + return parsed && typeof parsed === "object" ? parsed : {} } catch { return {} } } -export function saveConfig(patch: Partial): FridayConfig { - const next = { ...loadConfig(), ...patch } +/** Deep-merge plain objects (later wins); arrays and scalars are replaced, not concatenated. */ +function deepMerge(base: T, over: Partial): T { + const out: any = { ...base } + for (const [k, v] of Object.entries(over)) { + if (v === undefined) continue + const b = (out as any)[k] + out[k] = + b && v && typeof b === "object" && typeof v === "object" && !Array.isArray(b) && !Array.isArray(v) + ? deepMerge(b, v) + : v + } + return out +} + +/** + * Resolved config, layered like Claude Code: user (~/.friday/config.json) → project + * (.friday/settings.json, committed) → project-local (.friday/settings.local.json, gitignored). + * Later layers win; nested objects deep-merge so a project can override one key without dropping the + * rest. `cwd` defaults to the process cwd (the project root friday runs in). + */ +export function loadConfig(cwd: string = process.cwd()): FridayConfig { + let cfg = readLayer(configPath()) as FridayConfig + cfg = deepMerge(cfg, readLayer(projectConfigPath(cwd))) + cfg = deepMerge(cfg, readLayer(projectLocalConfigPath(cwd))) + return cfg +} + +/** + * Persist a patch. Writes to the USER config by default (the resolved view still layers project files + * on top at read time). Pass scope "project"/"local" to write the committed/gitignored project file. + */ +export function saveConfig(patch: Partial, scope: "user" | "project" | "local" = "user"): FridayConfig { + const file = scope === "project" ? projectConfigPath() : scope === "local" ? projectLocalConfigPath() : configPath() + const next = { ...readLayer(file), ...patch } try { - fs.mkdirSync(fridayDir(), { recursive: true }) - fs.writeFileSync(configPath(), JSON.stringify(next, null, 2)) + fs.mkdirSync(scope === "user" ? fridayDir() : path.dirname(file), { recursive: true }) + fs.writeFileSync(file, JSON.stringify(next, null, 2)) } catch { /* ignore */ } - return next + return loadConfig() } diff --git a/packages/core/src/engine.ts b/packages/core/src/engine.ts index b841832..7f4424e 100644 --- a/packages/core/src/engine.ts +++ b/packages/core/src/engine.ts @@ -36,7 +36,7 @@ import { TeamBoard } from "./board.ts" import { type CustomCommand, loadCommands } from "./commands.ts" import { loadConfig, saveConfig } from "./config.ts" import { type CronJob, loadCron, parseInterval, saveCron } from "./cron.ts" -import { openFleetWindows, openInteractiveWindow } from "./fleet.ts" +import { openFleetWindows, openInteractiveWindow, openTerminalRunning } from "./fleet.ts" import { cancelMic, type InputDevice, @@ -55,9 +55,21 @@ import { type RunnerHost, SessionRunner, type SessionStats } from "./runner.ts" import { SessionStore } from "./sessions.ts" import type { SkillInfo } from "./skills.ts" import type { StreamFn } from "./stream.ts" +import { + type TmuxLayout, + type TmuxPane, + tmuxAvailable, + wallAdd, + wallAttachCommand, + wallKill, + wallKillAll, + wallLayout, + wallPanes, +} from "./tmux.ts" export type { SessionStats } from "./runner.ts" export type { StreamFn } from "./stream.ts" +export type { TmuxLayout, TmuxPane } from "./tmux.ts" const now = () => Date.now() @@ -192,6 +204,8 @@ export class Engine { const row = resumed ?? this.store.buildRow([this.cwd], crypto.randomUUID(), now()) const runner = this.makeRunner(row) this.focusedId = runner.sessionId + // Restore a resumed session's own model/provider/mode/effort (new sessions keep the global default). + if (resumed) this.applySessionSelection(runner.sessionId) } // ---- shared infra ---- @@ -434,13 +448,40 @@ export class Engine { return id ? this.teamPayload(id) : null } /** Pop a single agent out into a real terminal window (tmux pane / OS terminal). */ - popoutAgent(sessionId: string): { ok: boolean; backend: string; opened: number } { + popoutAgent(sessionId: string): { ok: boolean; backend: string; opened: number; error?: string } { return openFleetWindows([sessionId]) } /** Open a NEW interactive friday window: fresh chat (no args) or resume `-s `, in `cwd`. */ - openInteractive(args: string[] = [], cwd?: string): { ok: boolean; backend: string; opened: number } { + openInteractive(args: string[] = [], cwd?: string): { ok: boolean; backend: string; opened: number; error?: string } { return openInteractiveWindow(args, cwd ?? this.currentCwd()) } + + // ---- tmux control center ("the wall"): real, tile-able, closable terminals managed from the dashboard ---- + /** Is tmux available? When false, the dashboard hides wall controls and uses separate windows. */ + tmuxOn(): boolean { + return tmuxAvailable() + } + /** Add a pane to the wall: a fresh chat (no args), a resumed session (`-s `), or a read-only + * watch (`attach `). `title` labels the pane in the tiled view. */ + wallOpen(args: string[], title: string, cwd?: string): Promise<{ ok: boolean; error?: string }> { + return wallAdd(args, cwd ?? this.currentCwd(), title) + } + wallList(): Promise { + return wallPanes() + } + wallRemove(paneId: string): Promise<{ ok: boolean; error?: string }> { + return wallKill(paneId) + } + wallRemoveAll(): Promise<{ ok: boolean; error?: string }> { + return wallKillAll() + } + wallArrange(layout: TmuxLayout): Promise<{ ok: boolean; error?: string }> { + return wallLayout(layout) + } + /** Open a real OS terminal window attached to the wall, so the user watches every pane tiled. */ + wallView(): { ok: boolean; backend: string; opened: number; error?: string } { + return openTerminalRunning(wallAttachCommand()) + } /** Force-activate deferred tools (by name prefix) for the focused session so the model can use them * immediately without calling tool_search first. Returns the number activated. */ activateTools(prefix: string): number { @@ -507,7 +548,7 @@ export class Engine { return uninstallComputerUse() } /** Open a viewer window per running task (tmux pane / OS terminal); returns the chosen backend. */ - openFleet(): { ok: boolean; backend: string; opened: number } { + openFleet(): { ok: boolean; backend: string; opened: number; error?: string } { const ids = this.taskList() .filter((t) => t.status === "running") .map((t) => t.id) @@ -711,9 +752,16 @@ export class Engine { currentRoots(): string[] { return this.focused().currentRoots() } - contextInfo(): { files: string[] } { + contextInfo(): { files: string[]; pinned: string[] } { return this.focused().contextInfo() } + /** Pin/unpin a file (relative to the focused session's primary root) into context for the session. */ + pinContextFile(rel: string): void { + this.focused().pinFile(rel) + } + unpinContextFile(rel: string): void { + this.focused().unpinFile(rel) + } listSkills(): SkillInfo[] { return this.focused().listSkills() } @@ -780,6 +828,7 @@ export class Engine { const prev = this.focusedId this.focusedId = id this.discardIfEmpty(prev) // throw away the empty session we just left + this.applySessionSelection(id) // restore this session's model/provider/mode/effort runner.emitState(true) } /** Add a directory to the focused session's workspace (no new session). */ @@ -915,7 +964,16 @@ export class Engine { this.modelReasoning = reasoning if (contextWindow && contextWindow > 0) this.contextWindow = contextWindow this.modelCost = cost ?? this.modelCost + // Save to the global config (the default for NEW sessions) AND to THIS session's meta, so resuming + // it later restores the model it was using rather than the current global default. saveConfig({ providerId, model, reasoning, contextWindow: this.contextWindow || undefined, cost: this.modelCost }) + this.store.setMeta(this.focusedId, { + providerId, + model, + reasoning, + contextWindow: this.contextWindow || undefined, + cost: this.modelCost, + }) this.dispatch(this.focusedId, { type: "model-changed", model, @@ -926,6 +984,32 @@ export class Engine { } setMode(m: ModeId): void { this.mode = m + this.store.setMeta(this.focusedId, { mode: m }) + } + /** + * Apply a session's persisted selection (model/provider/effort/mode) to the engine when it becomes + * focused, so a resumed/switched-to session restores exactly what it was using. New sessions have no + * meta, so the current global defaults stand. Emits model-changed; the TUI re-reads mode/effort from + * engine.selection() on the accompanying session-changed event. + */ + private applySessionSelection(id: string): void { + const m = this.store.loadMeta(id) + if (m.providerId) this.providerId = m.providerId + if (m.model) this.model = m.model + if (typeof m.reasoning === "boolean") this.modelReasoning = m.reasoning + if (m.contextWindow && m.contextWindow > 0) this.contextWindow = m.contextWindow + if (m.cost) this.modelCost = m.cost + if (m.mode) this.mode = m.mode + if (m.effort) this.effort = m.effort + if (m.model) { + this.dispatch(id, { + type: "model-changed", + model: this.model!, + provider: this.providerId!, + reasoning: this.modelReasoning, + contextWindow: this.contextWindow, + }) + } } // ---- command intake ---- @@ -949,10 +1033,12 @@ export class Engine { case "set-mode": this.mode = cmd.mode saveConfig({ mode: cmd.mode }) + this.store.setMeta(this.focusedId, { mode: cmd.mode }) break case "set-effort": this.effort = cmd.effort as Effort saveConfig({ effort: cmd.effort as Effort }) + this.store.setMeta(this.focusedId, { effort: cmd.effort as Effort }) break case "set-model": break diff --git a/packages/core/src/fleet.ts b/packages/core/src/fleet.ts index f0daef0..3fc9ca6 100644 --- a/packages/core/src/fleet.ts +++ b/packages/core/src/fleet.ts @@ -9,17 +9,28 @@ * failed or opened blank behind the editor); the common emulators on Linux. Unknown env degrades to * "none" so the caller can fall back to the in-TUI view rather than guessing. * + * Failures are never swallowed: `run()` captures stderr and every result carries an `error` string so + * the dashboard can tell the user WHY a window didn't open instead of a generic "no backend". + * * ponytail: covers tmux + macOS (open .command) + common Linux emulators; add more as users hit them. */ import fs from "node:fs" import os from "node:os" import path from "node:path" -/** Reconstruct how to launch friday itself. Dev: `bun