Skip to content

Commit 9de057c

Browse files
katipallyclaude
andcommitted
release v2.0.10: tmux fleet wall, delegated agent asks, full-session changes panel
- tmux "control center": tiled wall of real terminals for sessions/teams/swarms with arrange/close/attach (packages/core/src/tmux.ts), OS-window fallback - delegated agents (sub-agent/team/swarm) surface asks/permissions in the main view and bridge the answer back - changes panel is a full session summary (committed + uncommitted + removed) - resume restores per-session model/provider/effort/mode/summary/base/worktree - plan mode asks before planning; pause moved to Ctrl+Space - docs: agents-and-teams covers the tmux wall + delegated asks - CHANGELOG: cut [2.0.10] section Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0deec41 commit 9de057c

24 files changed

Lines changed: 828 additions & 153 deletions

CHANGELOG.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ All notable changes to this project are documented here. The format is based on
1111
1212
## [Unreleased]
1313

14+
## [2.0.10] - 2026-06-23
15+
1416
### Added
1517

1618
- **Pin context files**: a permanent, clickable context-files chip in the side
@@ -23,12 +25,35 @@ All notable changes to this project are documented here. The format is based on
2325
the Accessibility and Screen Recording panes directly so you can click-grant.
2426
- **Kitty keyboard protocol** enabled where supported, so chords legacy terminals
2527
can't encode (Shift+Enter, Cmd+Enter) work in kitty/WezTerm/Ghostty/modern iTerm2.
28+
- **tmux control center**: when tmux is available, the dashboard launches sessions/
29+
teams/swarms into a tiled tmux "wall" of real terminals — arrange (tiled/cols/
30+
rows/main), close each pane or all, and "open" a window attached to the wall to
31+
watch everything in one view. Falls back to separate OS windows without tmux.
32+
- **Delegated agents ask in the main view**: a subagent/team/swarm question or
33+
permission prompt now surfaces in your current view, labeled with the agent
34+
asking; you answer without switching and it bridges back to that agent.
2635

2736
### Changed
2837

2938
- **Pause keybinding** moved off `Shift+Esc` (terminals can't encode it — it
30-
arrived as plain Esc) onto **Ctrl+P** everywhere, plus **Cmd+Enter** on
31-
kitty-protocol terminals. Newline stays Shift+Enter / Option+Enter.
39+
arrived as plain Esc) onto **Ctrl+Space** (single-handed, works in every
40+
terminal), plus **Cmd+Enter** on kitty-protocol terminals. Newline stays
41+
Shift+Enter / Option+Enter.
42+
- **Changes panel is now a full session summary**: in a git repo it shows
43+
everything done since the session started — committed **and** uncommitted **and**
44+
removed files — by diffing the working tree against the commit captured at
45+
session start (committed files no longer vanish after `git commit`).
46+
- **Resume restores the whole session feel**: per-session model/provider/effort,
47+
mode, the compaction summary, the git base, and an active worktree all persist
48+
and come back on resume (previously these reset to the global default or were
49+
lost). Resuming a session from the dashboard also opens its window in that
50+
session's own folder.
51+
- **Plan mode** asks before planning: for vague requests it now uses `ask_user`
52+
with concrete options first, and `exit_plan` only carries a real implementation
53+
plan. A trivial request (a listing, a question) is answered directly instead of
54+
being dressed up as a "PLAN READY" gate (removed the last-message fallback).
55+
- **Settings** uses a horizontal tab bar (the same `Tabs` the dashboard uses) for
56+
a consistent feel; the context-files modal now has a proper search/input field.
3257
- **Side panel**: removed the redundant "context" title; the close control is
3358
right-aligned with its `ctrl+b` shortcut shown clearly.
3459
- **Plans/Todos/Changes sections**: reversed the washed-out hierarchy — section
@@ -44,6 +69,8 @@ All notable changes to this project are documented here. The format is based on
4469
- **Dashboard terminal windows** failing silently: the launcher now resolves the
4570
friday executable robustly (absolute path / PATH fallback) and surfaces the real
4671
error in a toast instead of a generic "no backend" message.
72+
- **Changes panel went stale after a rewind/redo**: `restoreCheckpoint` and
73+
`redoLast` now refresh the changes panel (todos/plans already refreshed).
4774

4875
## [2.0.9] - 2026-06-23
4976

docs/agents-and-teams.md

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,21 @@ them when done. The board tools are `board_post`, `board_read`,
3636
|---|---|---|
3737
| Dashboard | `/dashboard` or Ctrl+O | Sessions, Teams, and Swarm in one view. |
3838
| Console | `/console` or Ctrl+T | The live team cockpit: the shared board plus the roster. |
39-
| Fleet | `/fleet` | One external terminal window per running agent. |
40-
41-
`/fleet` needs a terminal backend: it uses tmux if you are already inside a tmux
42-
session, otherwise Terminal.app/iTerm on macOS or a detected emulator on Linux
43-
(wezterm, gnome-terminal, konsole, x-terminal-emulator). Windows is not
44-
supported. If no backend is found, watch the agents in the dashboard's Swarm tab
45-
instead.
39+
| Fleet | `/fleet` | One real terminal per running agent — a tiled tmux wall when tmux is present, otherwise separate OS windows. |
40+
41+
`/fleet` needs a terminal backend. When **tmux** is available it launches every
42+
session/team/swarm into a tiled "wall" of real terminals you control from the
43+
dashboard: re-arrange the panes (tiled, columns, rows, or main), close one pane
44+
or all of them, and "open the wall" to attach a terminal and watch every pane in
45+
one view. Without tmux it falls back to separate OS windows — Terminal.app/iTerm
46+
on macOS or a detected emulator on Linux (wezterm, gnome-terminal, konsole,
47+
x-terminal-emulator); Windows is not supported. If no backend is found, watch the
48+
agents in the dashboard's Swarm tab instead.
49+
50+
When a delegated agent (sub-agent, team member, or swarm worker) needs to ask a
51+
question or request a permission, the prompt surfaces in your current view —
52+
labeled with the agent that's asking — so you answer in place and the answer
53+
bridges back to that agent without switching windows.
4654

4755
## Scheduling and isolation
4856

packages/core/src/engine.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import { TeamBoard } from "./board.ts"
3636
import { type CustomCommand, loadCommands } from "./commands.ts"
3737
import { loadConfig, saveConfig } from "./config.ts"
3838
import { type CronJob, loadCron, parseInterval, saveCron } from "./cron.ts"
39-
import { openFleetWindows, openInteractiveWindow } from "./fleet.ts"
39+
import { openFleetWindows, openInteractiveWindow, openTerminalRunning } from "./fleet.ts"
4040
import {
4141
cancelMic,
4242
type InputDevice,
@@ -55,9 +55,21 @@ import { type RunnerHost, SessionRunner, type SessionStats } from "./runner.ts"
5555
import { SessionStore } from "./sessions.ts"
5656
import type { SkillInfo } from "./skills.ts"
5757
import type { StreamFn } from "./stream.ts"
58+
import {
59+
type TmuxLayout,
60+
type TmuxPane,
61+
tmuxAvailable,
62+
wallAdd,
63+
wallAttachCommand,
64+
wallKill,
65+
wallKillAll,
66+
wallLayout,
67+
wallPanes,
68+
} from "./tmux.ts"
5869

5970
export type { SessionStats } from "./runner.ts"
6071
export type { StreamFn } from "./stream.ts"
72+
export type { TmuxLayout, TmuxPane } from "./tmux.ts"
6173

6274
const now = () => Date.now()
6375

@@ -192,6 +204,8 @@ export class Engine {
192204
const row = resumed ?? this.store.buildRow([this.cwd], crypto.randomUUID(), now())
193205
const runner = this.makeRunner(row)
194206
this.focusedId = runner.sessionId
207+
// Restore a resumed session's own model/provider/mode/effort (new sessions keep the global default).
208+
if (resumed) this.applySessionSelection(runner.sessionId)
195209
}
196210

197211
// ---- shared infra ----
@@ -441,6 +455,33 @@ export class Engine {
441455
openInteractive(args: string[] = [], cwd?: string): { ok: boolean; backend: string; opened: number; error?: string } {
442456
return openInteractiveWindow(args, cwd ?? this.currentCwd())
443457
}
458+
459+
// ---- tmux control center ("the wall"): real, tile-able, closable terminals managed from the dashboard ----
460+
/** Is tmux available? When false, the dashboard hides wall controls and uses separate windows. */
461+
tmuxOn(): boolean {
462+
return tmuxAvailable()
463+
}
464+
/** Add a pane to the wall: a fresh chat (no args), a resumed session (`-s <id>`), or a read-only
465+
* watch (`attach <id>`). `title` labels the pane in the tiled view. */
466+
wallOpen(args: string[], title: string, cwd?: string): Promise<{ ok: boolean; error?: string }> {
467+
return wallAdd(args, cwd ?? this.currentCwd(), title)
468+
}
469+
wallList(): Promise<TmuxPane[]> {
470+
return wallPanes()
471+
}
472+
wallRemove(paneId: string): Promise<{ ok: boolean; error?: string }> {
473+
return wallKill(paneId)
474+
}
475+
wallRemoveAll(): Promise<{ ok: boolean; error?: string }> {
476+
return wallKillAll()
477+
}
478+
wallArrange(layout: TmuxLayout): Promise<{ ok: boolean; error?: string }> {
479+
return wallLayout(layout)
480+
}
481+
/** Open a real OS terminal window attached to the wall, so the user watches every pane tiled. */
482+
wallView(): { ok: boolean; backend: string; opened: number; error?: string } {
483+
return openTerminalRunning(wallAttachCommand())
484+
}
444485
/** Force-activate deferred tools (by name prefix) for the focused session so the model can use them
445486
* immediately without calling tool_search first. Returns the number activated. */
446487
activateTools(prefix: string): number {
@@ -787,6 +828,7 @@ export class Engine {
787828
const prev = this.focusedId
788829
this.focusedId = id
789830
this.discardIfEmpty(prev) // throw away the empty session we just left
831+
this.applySessionSelection(id) // restore this session's model/provider/mode/effort
790832
runner.emitState(true)
791833
}
792834
/** Add a directory to the focused session's workspace (no new session). */
@@ -922,7 +964,16 @@ export class Engine {
922964
this.modelReasoning = reasoning
923965
if (contextWindow && contextWindow > 0) this.contextWindow = contextWindow
924966
this.modelCost = cost ?? this.modelCost
967+
// Save to the global config (the default for NEW sessions) AND to THIS session's meta, so resuming
968+
// it later restores the model it was using rather than the current global default.
925969
saveConfig({ providerId, model, reasoning, contextWindow: this.contextWindow || undefined, cost: this.modelCost })
970+
this.store.setMeta(this.focusedId, {
971+
providerId,
972+
model,
973+
reasoning,
974+
contextWindow: this.contextWindow || undefined,
975+
cost: this.modelCost,
976+
})
926977
this.dispatch(this.focusedId, {
927978
type: "model-changed",
928979
model,
@@ -933,6 +984,32 @@ export class Engine {
933984
}
934985
setMode(m: ModeId): void {
935986
this.mode = m
987+
this.store.setMeta(this.focusedId, { mode: m })
988+
}
989+
/**
990+
* Apply a session's persisted selection (model/provider/effort/mode) to the engine when it becomes
991+
* focused, so a resumed/switched-to session restores exactly what it was using. New sessions have no
992+
* meta, so the current global defaults stand. Emits model-changed; the TUI re-reads mode/effort from
993+
* engine.selection() on the accompanying session-changed event.
994+
*/
995+
private applySessionSelection(id: string): void {
996+
const m = this.store.loadMeta(id)
997+
if (m.providerId) this.providerId = m.providerId
998+
if (m.model) this.model = m.model
999+
if (typeof m.reasoning === "boolean") this.modelReasoning = m.reasoning
1000+
if (m.contextWindow && m.contextWindow > 0) this.contextWindow = m.contextWindow
1001+
if (m.cost) this.modelCost = m.cost
1002+
if (m.mode) this.mode = m.mode
1003+
if (m.effort) this.effort = m.effort
1004+
if (m.model) {
1005+
this.dispatch(id, {
1006+
type: "model-changed",
1007+
model: this.model!,
1008+
provider: this.providerId!,
1009+
reasoning: this.modelReasoning,
1010+
contextWindow: this.contextWindow,
1011+
})
1012+
}
9361013
}
9371014

9381015
// ---- command intake ----
@@ -956,10 +1033,12 @@ export class Engine {
9561033
case "set-mode":
9571034
this.mode = cmd.mode
9581035
saveConfig({ mode: cmd.mode })
1036+
this.store.setMeta(this.focusedId, { mode: cmd.mode })
9591037
break
9601038
case "set-effort":
9611039
this.effort = cmd.effort as Effort
9621040
saveConfig({ effort: cmd.effort as Effort })
1041+
this.store.setMeta(this.focusedId, { effort: cmd.effort as Effort })
9631042
break
9641043
case "set-model":
9651044
break

packages/core/src/fleet.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ function sh(parts: string[]): string {
3939

4040
/** A shell command string for one window: optionally cd into `cwd`, then run friday with `args`.
4141
* Keeps the window open if friday ever exits (or fails to start) so it's never a blank black window —
42-
* the exit screen's resume hint stays visible, and any startup error is readable. */
43-
function fridayCommand(args: string[], cwd?: string): string {
42+
* the exit screen's resume hint stays visible, and any startup error is readable. Exported so the tmux
43+
* control center can run the SAME command inside a pane. */
44+
export function fridayCommand(args: string[], cwd?: string): string {
4445
const cmd = sh([...selfCmd(), ...args])
4546
const run = cwd ? `cd ${sh([cwd])} && ${cmd}` : cmd
4647
return `${run}; echo; echo '[friday exited — press Enter to close]'; read _`
@@ -151,3 +152,20 @@ export function openFleetWindows(ids: string[]): WinResult {
151152
export function openInteractiveWindow(args: string[] = [], cwd?: string): WinResult {
152153
return openWindows([fridayCommand(args, cwd)], false)
153154
}
155+
156+
/** Open ONE real OS terminal window running an arbitrary shell command — used to attach a window to
157+
* the tmux wall so the user can watch every pane tiled. Always a separate OS window (not a tmux split),
158+
* even when friday itself is running inside tmux. */
159+
export function openTerminalRunning(cmd: string): WinResult {
160+
const full = `${cmd}; echo; echo '[closed — press Enter to close]'; read _`
161+
if (process.platform === "darwin") {
162+
const { opened, error } = openMacWindows([full])
163+
const backend = process.env.TERM_PROGRAM === "iTerm.app" ? "iTerm" : "Terminal.app"
164+
return { ok: opened > 0, backend, opened, error }
165+
}
166+
if (process.platform === "linux") {
167+
const { opened, backend, error } = openLinuxTerminal([full])
168+
return { ok: opened > 0, backend, opened, error }
169+
}
170+
return { ok: false, backend: "none", opened: 0, error: `unsupported platform: ${process.platform}` }
171+
}

packages/core/src/git.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,60 @@ export async function gitIsTracked(cwd: string, relPath: string): Promise<boolea
6161
return res.ok
6262
}
6363

64+
/** The current commit (HEAD) sha, or null outside a repo / on an unborn branch. */
65+
export async function gitRevParse(cwd: string, ref = "HEAD"): Promise<string | null> {
66+
const res = await run(cwd, ["rev-parse", "--verify", "--quiet", ref])
67+
const sha = res.out.trim()
68+
return res.ok && sha ? sha : null
69+
}
70+
71+
/**
72+
* Everything changed since `base` (a commit captured at session start): committed AND uncommitted AND
73+
* deletions, in one list — `git diff <base>` compares that commit straight to the working tree, so
74+
* intermediate commits collapse into the net change. Untracked files are appended as adds. This is the
75+
* full "what happened this session" footprint, robust to commits the snapshot tracker would drop.
76+
*/
77+
export async function gitSessionChanges(cwd: string, base: string): Promise<GitFile[]> {
78+
const numstat = await run(cwd, ["diff", "--numstat", base])
79+
const counts = new Map<string, { added: number; removed: number }>()
80+
for (const line of numstat.out.split("\n")) {
81+
const m = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line)
82+
if (!m) continue
83+
// Renames render as "old => new" or "{a => b}/c"; take the resulting path's last token.
84+
const p = m[3]!.includes(" => ")
85+
? m[3]!
86+
.replace(/\{.*? => (.*?)\}/, "$1")
87+
.split(" => ")
88+
.pop()!
89+
.trim()
90+
: m[3]!
91+
counts.set(p, { added: m[1] === "-" ? 0 : Number(m[1]), removed: m[2] === "-" ? 0 : Number(m[2]) })
92+
}
93+
const nameStatus = await run(cwd, ["diff", "--name-status", base])
94+
const files: GitFile[] = []
95+
const seen = new Set<string>()
96+
for (const line of nameStatus.out.split("\n")) {
97+
if (!line.trim()) continue
98+
const parts = line.split("\t")
99+
const code = parts[0]!.trim()
100+
const status = code[0]! // M, A, D, R, C, T…
101+
const p = (status === "R" || status === "C" ? parts[2] : parts[1])?.trim()
102+
if (!p || seen.has(p)) continue
103+
seen.add(p)
104+
const c = counts.get(p) ?? { added: 0, removed: 0 }
105+
files.push({ path: p, status: status === "R" || status === "C" ? "A" : status, added: c.added, removed: c.removed })
106+
}
107+
// Untracked (new, never-added) files aren't in the diff — surface them as adds.
108+
const untracked = await run(cwd, ["ls-files", "--others", "--exclude-standard"])
109+
for (const line of untracked.out.split("\n")) {
110+
const p = line.trim()
111+
if (!p || seen.has(p)) continue
112+
seen.add(p)
113+
files.push({ path: p, status: "A", added: 0, removed: 0 })
114+
}
115+
return files
116+
}
117+
64118
/** The working-tree diff (staged + unstaged), truncated for prompting. */
65119
export async function gitDiff(cwd: string, maxChars = 12_000): Promise<string> {
66120
const res = await run(cwd, ["diff", "HEAD"])

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export { systemPrompt } from "./prompt.ts"
2222
export { bashRisk, matchesList } from "./safety.ts"
2323
export { type SessionRow, SessionStore } from "./sessions.ts"
2424
export { loadSkills, type Skill, type SkillInfo } from "./skills.ts"
25+
export type { TmuxLayout, TmuxPane } from "./tmux.ts"
2526
export {
2627
compareSemver,
2728
detectInstallMethod,

packages/core/src/keybindings.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ export const DEFAULT_KEYBINDINGS = {
1414
"dashboard.toggle": "ctrl+o",
1515
"history.open": "ctrl+y",
1616
"mode.cycle": "shift+tab",
17-
// Ctrl+P transmits in every terminal (Shift+Esc could not — Shift never modifies the Esc byte, so
18-
// it arrived as plain Esc). On kitty-protocol terminals Cmd/Super+Enter also fires pause; that's
19-
// handled in App.tsx's global handler, not here, since the textarea can't tell Option from Cmd.
20-
"pause.open": "ctrl+p",
17+
// Ctrl+Space transmits in every terminal (a NUL byte → name "space" + ctrl) and is single-handed
18+
// (pinky + thumb). Shift+Esc couldn't work — Shift never modifies the Esc byte, so it arrived as
19+
// plain Esc. On kitty-protocol terminals Cmd/Super+Enter also fires pause; that's handled in
20+
// App.tsx's global handler, not here, since the textarea can't tell Option from Cmd.
21+
"pause.open": "ctrl+space",
2122
// Ctrl+G (BEL) — reliable across terminals. Ctrl+, can't be used: terminals fold it onto Ctrl+\ (0x1c).
2223
"settings.open": "ctrl+g",
2324
"help.open": "f1",

packages/core/src/prompt.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,11 @@ function modePostureNote(mode: ModeId): string {
146146
case "plan":
147147
return [
148148
"\n# Mode: plan",
149-
"You are in read-only plan mode. You can ONLY investigate — read files and search the codebase (read, ls, glob, grep, lsp_*) and ask the user questions. The edit and bash tools are intentionally unavailable here; do not attempt to change anything or run commands. Your job is to produce a plan, not to carry it out.",
150-
"When — and ONLY when — you have a complete, concrete plan, call exit_plan({ plan }) with an ordered, step-by-step plan in markdown that cites the specific files to change.",
151-
"exit_plan is the ONLY way the user reviews and approves your plan, so do not just describe the plan in prose and stop — always end a finished investigation by calling exit_plan. The user then chooses whether and how to execute it.",
149+
"You are in read-only plan mode. You can ONLY investigate — read files and search the codebase (read, ls, glob, grep, lsp_*) and ask the user questions. The edit and bash tools are intentionally unavailable here; do not change anything or run commands. Your job is to design an implementation plan, not to carry it out.",
150+
"ASK FIRST when the request is vague or under-specified. If the goal, scope, or approach is ambiguous, or there are multiple sensible ways to do it, call ask_user with concrete options BEFORE planning — don't guess. Do not reference \"the plan\" in these questions (the user can't see a plan yet); ask about the actual requirements/approach. Skip questions only when the request is already specific.",
151+
"PRODUCE A REAL PLAN, not output. exit_plan({ plan }) is for an ordered, step-by-step implementation plan in markdown: a short goal, the approach, the concrete steps, and the specific files to create/change. It is NOT for dumping command output, a file listing, or an answer to a question — that is not a plan.",
152+
"If the user's request is NOT an implementation task — a question, a lookup, a file listing, an explanation — just answer it directly in prose and stop. Do NOT call exit_plan and do NOT fabricate a plan for it.",
153+
"When — and only when — you have a complete, concrete implementation plan, call exit_plan({ plan }). That is the only way the user reviews and approves it; don't describe a plan in prose and stop. The user then chooses whether and how to execute.",
152154
].join("\n")
153155
case "yolo":
154156
return "\n# Mode: yolo\nFull autonomy is granted; proceed without asking for confirmation."

0 commit comments

Comments
 (0)