Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 15 additions & 7 deletions docs/agents-and-teams.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 24 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 43 additions & 8 deletions packages/core/src/config.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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<FridayConfig> {
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>): FridayConfig {
const next = { ...loadConfig(), ...patch }
/** Deep-merge plain objects (later wins); arrays and scalars are replaced, not concatenated. */
function deepMerge<T>(base: T, over: Partial<T>): 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<FridayConfig>, 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()
}
96 changes: 91 additions & 5 deletions packages/core/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()

Expand Down Expand Up @@ -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 ----
Expand Down Expand Up @@ -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 <id>`, 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 <id>`), or a read-only
* watch (`attach <id>`). `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<TmuxPane[]> {
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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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). */
Expand Down Expand Up @@ -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,
Expand All @@ -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 ----
Expand All @@ -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
Expand Down
Loading
Loading