16 Commits

Author SHA1 Message Date
ec0c148164 Update PTY start call sites 2026-05-27 13:21:18 +01:00
9aecc8b7a2 Scaffold loopback daemon client split 2026-05-27 13:19:56 +01:00
e63bdad5e1 Add daemon client protocol frames 2026-05-27 13:19:42 +01:00
b72a32bbc6 Fix PTY workdir and process group teardown 2026-05-27 13:19:35 +01:00
da46340a82 Merge pull request 'Work through TODO fixes' (#8) from todo-fixes into main 2026-05-25 13:13:25 +01:00
d2342f99cf Show every agent tab's summary, not just the focused one
The tab bar's row-2 summary was painted only for the active tab. Add a
per-child summaryTextFor/summaryRawFor helper (active variants now
delegate to it), carry each tab's childID on its tabRect, and loop over
all visible tabs so each renders its own summary under its column.
Layout is unchanged (still 3 rows); narrow tabs clip as before.

Resolves the per-tab summary TODO item.
2026-05-25 13:06:53 +01:00
178b4437b1 Give injected agent submit Enter a longer settle delay
The trailing CR that submits orchestrator-injected input was written
only 15ms after the body, inside TUI agents' paste-coalescing window,
so codex (and other paste-detecting agents) intermittently swallowed it
as a newline and left the message composed but unsent. Centralize the
per-piece timing in a pure pieceWriteDelay helper: keep 15ms between
body lines but give the final lone Enter a 100ms settle gap so the
agent closes the preceding burst and registers the CR as submit. Covers
send_input, send_message, timers, and the spawn initial prompt (all go
through writeInput).

Resolves the codex composer-submit TODO item.
2026-05-25 13:00:54 +01:00
0725375755 Hold codex in thinking while a turn is running
Codex uses the osc_title_stability idle strategy, but it draws its
progress in the pane body ('Working … esc to interrupt'), not the OSC
title. The title goes stable mid-turn, so ~2s later the classifier
declared codex idle while it was still working. Add a thinking-promoter
pattern ((?i)esc to interrupt) to the codex built-in preset; classify()
checks promoter regexes against the rendered screen before the
title-stability verdict, so codex stays in thinking until the turn's
in-progress footer actually disappears.

Resolves the [CODEX IDLE] TODO item.
2026-05-25 12:43:56 +01:00
3022e4adeb Track per-tab summary visibility TODO 2026-05-25 12:40:23 +01:00
7b5a22618f Dispatch MCP requests concurrently per connection
handleConn processed requests serially, so a slow tool (e.g.
wait_for_pattern with a 300s timeout) monopolized the single per-agent
MCP connection and every queued call timed out behind it. Handle each
request in its own goroutine, serialize responses through a per-conn
write mutex (full response written atomically, partial writes handled),
copy the request line before handing it off (bufio reuses its buffer),
and wait on a WaitGroup before closing the conn so in-flight handlers
finish cleanly. Greeting stays sequential; notifications still get no
response.

Resolves the [MCP TIMEOUT] TODO item.
2026-05-25 12:39:31 +01:00
53f06b604f Normalize whitespace in grid get_process_output to save tokens
Grid snapshots pad every row to the full terminal width and leave the
bottom of the screen blank, so MCP grid reads carried a lot of dead
whitespace. Add normalizeGridText (CRLF/lone-CR to LF, right-trim each
line, collapse blank runs to a single blank, drop leading/trailing
blanks) and apply it to the grid branch of GetProcessOutput only.
Stream output, raw output, and WaitForPattern matching are untouched.

Resolves the terminal-read newline/token-waste TODO item.
2026-05-25 12:33:59 +01:00
50fd7be70d Escalate agent Close to SIGKILL so it terminates in one action
Agent 'Close' (agent-close) sent a single SIGTERM via Session.Kill and
never escalated, so an agent that traps/ignores SIGTERM (e.g. opencode)
stayed in the running tab bar until the user closed it again. Add
Session.Terminate, which reuses terminateAndWait (SIGTERM, wait, then
SIGKILL) but preserves the session entry so the exited pane stays
readable, and route handleChildClose's agent path through it in a
goroutine to keep the UI responsive during the stop timeout.

Resolves the opencode double-close TODO item.
2026-05-25 12:30:13 +01:00
96f7c66d5f Add scratchpad_delete MCP tool
Mirrors the existing scratchpad_* tools end-to-end: catalog schema,
dispatch, ToolHost.ScratchpadDelete, and a host method that delegates to
scratchpad.Store.Delete and fires scratchpadsChanged() on success so the
sidebar refreshes. Missing-pad errors surface rather than being masked.

Resolves the [MCP SCRATCHPAD DELETE] TODO item.
2026-05-25 12:23:58 +01:00
f61788eff2 Work through TODO fixes 2026-05-21 15:45:01 +01:00
c1b66f9f8a Merge pull request 'Show idle state in the top tab bar + release v0.0.7' (#7) from worktree-timers-cancel-on-close into main 2026-05-18 13:25:38 +01:00
412b1167a2 Cancel pending timers when a child is closed (#6)
Co-authored-by: Harry Bayliss <harry@hjb.dev>
Co-committed-by: Harry Bayliss <harry@hjb.dev>
2026-05-18 12:46:50 +01:00
39 changed files with 2036 additions and 117 deletions

1
.gitignore vendored
View File

@@ -7,4 +7,5 @@ spike-report-*.txt
/bin/ /bin/
/spike /spike
/.worktrees/ /.worktrees/
/.claude/worktrees/
internal/harness/.artifacts/ internal/harness/.artifacts/

View File

@@ -6,6 +6,44 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Added
- MCP clients can now call `scratchpad_delete` with a scratchpad name
to remove a shared project scratchpad.
### Changed
- The tab bar now shows each visible agent tab's own summary instead
of only rendering the focused tab's summary.
- Grid-mode `get_process_output` now returns whitespace-normalized
text to avoid sending padded terminal rows and repeated blank lines
over MCP.
### Fixed
- Injected agent input now sends the submit Enter as a separated,
settled keystroke so messages reliably submit instead of sometimes
sitting unsent in the composer.
- Codex agents are no longer reported idle while a turn is still
running.
- Slow MCP tool calls such as `wait_for_pattern` no longer block later
tool calls on the same MCP connection.
- Closing an agent now escalates from SIGTERM to SIGKILL when needed,
so agents that ignore SIGTERM disappear from the running tab bar
after one Close action while keeping their exited pane readable.
- Sidebar timer indicators now repaint as their visible countdown
value changes, so labels progress from minutes to seconds without
waiting for unrelated terminal output or focus changes.
- Raw terminal focused actions now show a single `Close` row instead
of separate stop/delete-style lifecycle choices that did the same
thing for ephemeral terminal panes.
- Restarting a process from the palette now restores the focused pane
and host chrome before waiting for the old process to exit, so the
tab bar and sidebar do not disappear during slow restarts.
- Deleting the focused scratchpad now moves focus to another
scratchpad when one exists, or back to a running terminal/agent
instead of dropping into the empty state.
- Multiline paste into raw terminal and command panes no longer pays
the agent-specific per-Enter delay, making large pasted input arrive
as one PTY write outside Claude/Codex/OpenCode panes.
## [0.0.7] - 2026-05-18 ## [0.0.7] - 2026-05-18
### Added ### Added

View File

@@ -108,7 +108,7 @@ func run(argv []string, cols, rows uint16, idleMS int, followHost, stdinPassthro
} }
defer em.Close() defer em.Close()
child, err := pty.Start(argv, nil, cols, rows) child, err := pty.Start(argv, nil, "", cols, rows)
if err != nil { if err != nil {
return fmt.Errorf("pty: %w", err) return fmt.Errorf("pty: %w", err)
} }

View File

@@ -161,6 +161,21 @@ func Run(ctx context.Context, opts Options) error {
// ctx is cancelled. // ctx is cancelled.
go sess.runClassifier(ctx) go sess.runClassifier(ctx)
core := &headlessCore{
projectDir: opts.ProjectDir,
projectKey: opts.ProjectKey,
presets: presets,
settings: appSettings,
pads: pads,
trustStore: trustStore,
persistStore: persistStore,
mcpSrv: mcpSrv,
sess: sess,
launcher: launcher,
host: host,
}
_ = core
st := &uiState{ st := &uiState{
sess: sess, sess: sess,
presets: presets, presets: presets,
@@ -171,6 +186,12 @@ func Run(ctx context.Context, opts Options) error {
timers: host.timers, timers: host.timers,
hostCols: cols, hostCols: cols,
hostRows: rows, hostRows: rows,
view: ClientView{
ID: "loopback",
ProjectKey: opts.ProjectKey,
Cols: cols,
Rows: rows,
},
stdinTTY: term.IsTerminal(int(os.Stdin.Fd())), stdinTTY: term.IsTerminal(int(os.Stdin.Fd())),
metrics: metrics, metrics: metrics,
settings: appSettings, settings: appSettings,
@@ -252,6 +273,7 @@ func Run(ctx context.Context, opts Options) error {
} }
st.dimsMu.Lock() st.dimsMu.Lock()
st.hostCols, st.hostRows = c, r st.hostCols, st.hostRows = c, r
st.view.Resize(c, r)
l := st.layoutLocked() l := st.layoutLocked()
st.dimsMu.Unlock() st.dimsMu.Unlock()
st.mu.Lock() st.mu.Lock()
@@ -326,6 +348,15 @@ func Run(ctx context.Context, opts Options) error {
} }
}() }()
// Timer sidebar refresher: countdown labels are computed at draw
// time, so wake the sidebar when the next visible timer bucket is
// due to change even if no child PTY output arrives.
wg.Add(1)
go func() {
defer wg.Done()
st.runTimerSidebarRefresher(ctx)
}()
// Marquee ticker: while a focused sidebar row's name overflows the // Marquee ticker: while a focused sidebar row's name overflows the
// rail width, advance the pause-scroll-pause animation by marking // rail width, advance the pause-scroll-pause animation by marking
// the sidebar dirty every marqueeStep. The chrome ticker above does // the sidebar dirty every marqueeStep. The chrome ticker above does
@@ -399,6 +430,7 @@ type uiState struct {
outMu sync.Mutex outMu sync.Mutex
mu sync.Mutex mu sync.Mutex
view ClientView
palette *paletteState palette *paletteState
focusedID string focusedID string
focusedName string focusedName string
@@ -505,7 +537,14 @@ func (st *uiState) dbgf(format string, args ...any) {
} }
func (st *uiState) activeSummaryText(width int) string { func (st *uiState) activeSummaryText(width int) string {
text := st.activeSummaryRaw() st.mu.Lock()
active := st.activeAgentID
st.mu.Unlock()
return st.summaryTextFor(active, width)
}
func (st *uiState) summaryTextFor(childID string, width int) string {
text := st.summaryRawFor(childID)
if text == "" || width <= 0 { if text == "" || width <= 0 {
return "" return ""
} }
@@ -516,7 +555,14 @@ func (st *uiState) activeSummaryText(width int) string {
} }
func (st *uiState) activeSummaryRaw() string { func (st *uiState) activeSummaryRaw() string {
if st.summaries == nil { st.mu.Lock()
active := st.activeAgentID
st.mu.Unlock()
return st.summaryRawFor(active)
}
func (st *uiState) summaryRawFor(childID string) string {
if st.summaries == nil || childID == "" {
return "" return ""
} }
st.settingsMu.Lock() st.settingsMu.Lock()
@@ -525,13 +571,7 @@ func (st *uiState) activeSummaryRaw() string {
if !enabled { if !enabled {
return "" return ""
} }
st.mu.Lock() sum := st.summaries.Summary(childID)
active := st.activeAgentID
st.mu.Unlock()
if active == "" {
return ""
}
sum := st.summaries.Summary(active)
text := strings.TrimSpace(sum.Text) text := strings.TrimSpace(sum.Text)
if text == "" { if text == "" {
return "" return ""
@@ -557,6 +597,21 @@ func (st *uiState) promptTrust(processID, presetName, reason string) {
st.drawStatusLine() st.drawStatusLine()
} }
func (st *uiState) focusChildLocked(c *Child) {
st.focusedPad = ""
st.focusedID = c.ID
st.focusedName = c.DisplayName()
st.view.FocusChild(c.ID)
}
func (st *uiState) focusPadLocked(name string) {
st.view.FocusPad(name)
st.focusedPad = st.view.FocusedPad
st.focusedID = st.view.FocusedID
st.padOffset = st.view.PadOffset
st.padOffsetName = st.view.PadOffsetName
}
// focusProcess is the SPEC §7 select_process hook. Routes through the // focusProcess is the SPEC §7 select_process hook. Routes through the
// normal focus-change path; only takes effect if the process exists. // normal focus-change path; only takes effect if the process exists.
func (st *uiState) focusProcess(processID string) { func (st *uiState) focusProcess(processID string) {
@@ -569,9 +624,7 @@ func (st *uiState) focusProcess(processID string) {
onAlt := childIsOnAlt(c) onAlt := childIsOnAlt(c)
st.mu.Lock() st.mu.Lock()
leavingPad := st.focusedPad != "" leavingPad := st.focusedPad != ""
st.focusedPad = "" st.focusChildLocked(c)
st.focusedID = c.ID
st.focusedName = c.DisplayName()
st.updateActiveAgentLocked(c) st.updateActiveAgentLocked(c)
r := newViewportRenderer(layout) r := newViewportRenderer(layout)
r.SetChildOnAlt(onAlt) r.SetChildOnAlt(onAlt)
@@ -634,12 +687,7 @@ func (st *uiState) focusScratchpad(name string) {
} }
st.marquee.reset() st.marquee.reset()
st.mu.Lock() st.mu.Lock()
if st.padOffsetName != name { st.focusPadLocked(name)
st.padOffset = 0
st.padOffsetName = name
}
st.focusedPad = name
st.focusedID = ""
st.focusedName = name st.focusedName = name
st.renderer = nil st.renderer = nil
st.mu.Unlock() st.mu.Unlock()
@@ -671,6 +719,20 @@ func (st *uiState) clearViewportArea() {
_, _ = os.Stdout.WriteString(b.String()) _, _ = os.Stdout.WriteString(b.String())
} }
func (st *uiState) repaintFocusedWithChrome() {
st.mu.Lock()
padFocused := st.focusedPad != ""
st.mu.Unlock()
if padFocused {
st.repaintFocusedPad()
} else {
st.repaintFocused()
}
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
func (st *uiState) restartFocusedCommand(processID string) { func (st *uiState) restartFocusedCommand(processID string) {
c := st.sess.FindChild(processID) c := st.sess.FindChild(processID)
if c == nil || c.Kind != KindCommand { if c == nil || c.Kind != KindCommand {
@@ -680,21 +742,24 @@ func (st *uiState) restartFocusedCommand(processID string) {
layout := st.layoutSnapshot() layout := st.layoutSnapshot()
renderer := newViewportRenderer(layout) renderer := newViewportRenderer(layout)
st.mu.Lock() st.mu.Lock()
st.focusedID = c.ID st.focusChildLocked(c)
st.focusedName = c.DisplayName()
st.renderer = renderer st.renderer = renderer
st.repaintNextPTY = c.ID st.repaintNextPTY = c.ID
st.repaintNextPTYBudget = 2 st.repaintNextPTYBudget = 2
st.mu.Unlock() st.mu.Unlock()
st.outMu.Lock() st.repaintFocusedWithChrome()
_, _ = os.Stdout.Write(renderer.ClearViewport())
st.outMu.Unlock()
if err := st.sess.Restart(c.ID, syscall.SIGTERM, layout.childCols(), layout.childRows()); err != nil { if err := st.sess.Restart(c.ID, syscall.SIGTERM, layout.childCols(), layout.childRows()); err != nil {
st.flashError(fmt.Sprintf("restart %s: %v", c.DisplayName(), err)) st.flashError(fmt.Sprintf("restart %s: %v", c.DisplayName(), err))
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
return return
} }
st.outMu.Lock()
_, _ = os.Stdout.Write(renderer.ClearViewport())
st.outMu.Unlock()
st.moveToViewportOrigin() st.moveToViewportOrigin()
st.drawTabBar() st.drawTabBar()
st.drawSidebar() st.drawSidebar()
@@ -712,6 +777,7 @@ func (st *uiState) updateActiveAgentLocked(c *Child) {
} }
if c.ParentID == "" { if c.ParentID == "" {
st.activeAgentID = c.ID st.activeAgentID = c.ID
st.view.ActiveAgentID = c.ID
return return
} }
// Walk up to the top-level agent. // Walk up to the top-level agent.
@@ -725,6 +791,7 @@ func (st *uiState) updateActiveAgentLocked(c *Child) {
} }
if root.Kind == KindAgent && root.ParentID == "" { if root.Kind == KindAgent && root.ParentID == "" {
st.activeAgentID = root.ID st.activeAgentID = root.ID
st.view.ActiveAgentID = root.ID
} }
} }
@@ -741,12 +808,7 @@ func (st *uiState) notifyAttention(childID, reason string) {
} }
func (st *uiState) scratchpadsChanged() { func (st *uiState) scratchpadsChanged() {
st.padsCacheMu.Lock() st.invalidateScratchpadsCache()
st.padsCache = nil
st.padsCacheMu.Unlock()
st.chromeCacheMu.Lock()
st.sidebarCache = ""
st.chromeCacheMu.Unlock()
st.drawSidebar() st.drawSidebar()
st.mu.Lock() st.mu.Lock()
focusedPad := st.focusedPad focusedPad := st.focusedPad
@@ -756,6 +818,15 @@ func (st *uiState) scratchpadsChanged() {
} }
} }
func (st *uiState) invalidateScratchpadsCache() {
st.padsCacheMu.Lock()
st.padsCache = nil
st.padsCacheMu.Unlock()
st.chromeCacheMu.Lock()
st.sidebarCache = ""
st.chromeCacheMu.Unlock()
}
// OnChildSpawned auto-focuses the new child when the spawn came from // OnChildSpawned auto-focuses the new child when the spawn came from
// the user (palette, persistence restore, or an external MCP client with // the user (palette, persistence restore, or an external MCP client with
// no resolved identity). When ParentID is set — meaning a patterm-managed // no resolved identity). When ParentID is set — meaning a patterm-managed
@@ -783,9 +854,7 @@ func (st *uiState) OnChildSpawned(c *Child) {
layout := st.layoutSnapshot() layout := st.layoutSnapshot()
onAlt := childIsOnAlt(c) onAlt := childIsOnAlt(c)
st.mu.Lock() st.mu.Lock()
st.focusedPad = "" st.focusChildLocked(c)
st.focusedID = c.ID
st.focusedName = c.DisplayName()
st.updateActiveAgentLocked(c) st.updateActiveAgentLocked(c)
renderer := newViewportRenderer(layout) renderer := newViewportRenderer(layout)
renderer.SetChildOnAlt(onAlt) renderer.SetChildOnAlt(onAlt)
@@ -860,10 +929,10 @@ func (st *uiState) OnChildExited(c *Child) {
if next == nil { if next == nil {
st.focusedID = "" st.focusedID = ""
st.focusedName = "" st.focusedName = ""
st.view.FocusedID = ""
renderEmpty = true renderEmpty = true
} else { } else {
st.focusedID = next.ID st.focusChildLocked(next)
st.focusedName = next.DisplayName()
st.updateActiveAgentLocked(next) st.updateActiveAgentLocked(next)
st.renderer = newViewportRenderer(layout) st.renderer = newViewportRenderer(layout)
} }
@@ -872,6 +941,7 @@ func (st *uiState) OnChildExited(c *Child) {
// The active agent died; pin the agent tree to whatever agent // The active agent died; pin the agent tree to whatever agent
// root is still running, or clear it if none remain. // root is still running, or clear it if none remain.
st.activeAgentID = firstRunningAgentID(st.sess.Children()) st.activeAgentID = firstRunningAgentID(st.sess.Children())
st.view.ActiveAgentID = st.activeAgentID
} }
if st.palette != nil { if st.palette != nil {
st.palette.children = st.sess.Children() st.palette.children = st.sess.Children()
@@ -1143,6 +1213,55 @@ func (st *uiState) markSidebarDirty() {
} }
} }
func (st *uiState) runTimerSidebarRefresher(ctx context.Context) {
if st.timers == nil {
<-ctx.Done()
return
}
changes := st.timers.changeEvents()
var timer *time.Timer
var timerC <-chan time.Time
stop := func() {
if timer == nil {
return
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer = nil
timerC = nil
}
arm := func() {
stop()
wait, ok := st.timers.nextSidebarRefreshAfter(time.Now())
if !ok {
return
}
if wait < timerSidebarMinRefresh {
wait = timerSidebarMinRefresh
}
timer = time.NewTimer(wait)
timerC = timer.C
}
defer stop()
arm()
for {
select {
case <-ctx.Done():
return
case <-changes:
st.markSidebarDirty()
arm()
case <-timerC:
st.markSidebarDirty()
arm()
}
}
}
func (st *uiState) invalidateChromeCache() { func (st *uiState) invalidateChromeCache() {
st.chromeCacheMu.Lock() st.chromeCacheMu.Lock()
st.tabBarCache = "" st.tabBarCache = ""
@@ -1299,7 +1418,10 @@ func (st *uiState) renderEmptyState() {
func (st *uiState) hostSizeSnapshot() (uint16, uint16) { func (st *uiState) hostSizeSnapshot() (uint16, uint16) {
st.dimsMu.Lock() st.dimsMu.Lock()
defer st.dimsMu.Unlock() defer st.dimsMu.Unlock()
if st.view.Cols == 0 || st.view.Rows == 0 {
return st.hostCols, st.hostRows return st.hostCols, st.hostRows
}
return st.view.Cols, st.view.Rows
} }
func (st *uiState) layoutSnapshot() terminalLayout { func (st *uiState) layoutSnapshot() terminalLayout {
@@ -1309,7 +1431,10 @@ func (st *uiState) layoutSnapshot() terminalLayout {
} }
func (st *uiState) layoutLocked() terminalLayout { func (st *uiState) layoutLocked() terminalLayout {
if st.view.Cols == 0 || st.view.Rows == 0 {
return newTerminalLayout(st.hostCols, st.hostRows) return newTerminalLayout(st.hostCols, st.hostRows)
}
return newTerminalLayout(st.view.Cols, st.view.Rows)
} }
// splitOnEnter walks input and returns each Enter byte (CR or LF) as // splitOnEnter walks input and returns each Enter byte (CR or LF) as
@@ -1433,9 +1558,10 @@ func (st *uiState) processStdin(chunk []byte) {
if st.focusedID != "" { if st.focusedID != "" {
if c := st.sess.FindChild(st.focusedID); c != nil && c.Status() == StatusRunning { if c := st.sess.FindChild(st.focusedID); c != nil && c.Status() == StatusRunning {
prev := c.Owner() prev := c.Owner()
// InjectAsUser splits Enter bytes onto their own // Agent panes split Enter bytes onto their own writes
// writes so claude / codex / opencode don't treat a // so claude / codex / opencode don't treat a
// "text\r" batch as a paste. // "text\r" batch as a paste. Raw terminals keep paste
// bytes batched.
_ = c.InjectAsUser(forward) _ = c.InjectAsUser(forward)
if st.summaries != nil { if st.summaries != nil {
st.summaries.ObserveHumanInput(c.ID, forward) st.summaries.ObserveHumanInput(c.ID, forward)
@@ -1997,9 +2123,7 @@ func (st *uiState) closePalette(action paletteAction) {
layout := st.layoutSnapshot() layout := st.layoutSnapshot()
st.mu.Lock() st.mu.Lock()
leavingPad := st.focusedPad != "" leavingPad := st.focusedPad != ""
st.focusedPad = "" st.focusChildLocked(c)
st.focusedID = action.childID
st.focusedName = c.DisplayName()
st.updateActiveAgentLocked(c) st.updateActiveAgentLocked(c)
st.renderer = newViewportRenderer(layout) st.renderer = newViewportRenderer(layout)
st.mu.Unlock() st.mu.Unlock()
@@ -2131,20 +2255,45 @@ func (st *uiState) handlePadDelete(name string) {
st.repaintFocused() st.repaintFocused()
return return
} }
st.mu.Lock()
wasFocused := st.focusedPad == name
st.mu.Unlock()
if err := st.pads.Delete(name); err != nil { if err := st.pads.Delete(name); err != nil {
st.flashError(fmt.Sprintf("delete %s: %v", name, err)) st.flashError(fmt.Sprintf("delete %s: %v", name, err))
return return
} }
if wasFocused {
st.invalidateScratchpadsCache()
if entries := st.padsList(); len(entries) > 0 {
next := entries[0].Name
st.mu.Lock() st.mu.Lock()
if st.focusedPad == name { st.focusPadLocked(next)
st.focusedPad = "" st.focusedName = next
}
st.mu.Unlock() st.mu.Unlock()
st.scratchpadsChanged() st.repaintFocusedWithChrome()
st.repaintFocused() return
}
if next := firstRunningTopLevel(st.sess.Children()); next != nil {
st.focusProcess(next.ID)
return
}
st.mu.Lock()
st.focusedPad = ""
st.view.FocusedPad = ""
st.focusedName = ""
st.padOffset = 0
st.padOffsetName = ""
st.view.PadOffset = 0
st.view.PadOffsetName = ""
st.mu.Unlock()
st.renderEmptyState()
st.drawTabBar() st.drawTabBar()
st.drawSidebar() st.drawSidebar()
st.drawStatusLine() st.drawStatusLine()
return
}
st.scratchpadsChanged()
st.repaintFocusedWithChrome()
} }
func (st *uiState) handlePadRename(oldName, newName string) { func (st *uiState) handlePadRename(oldName, newName string) {
@@ -2162,7 +2311,7 @@ func (st *uiState) handlePadRename(oldName, newName string) {
} }
st.mu.Lock() st.mu.Lock()
if st.focusedPad == oldName { if st.focusedPad == oldName {
st.focusedPad = newName st.focusPadLocked(newName)
} }
st.mu.Unlock() st.mu.Unlock()
st.scratchpadsChanged() st.scratchpadsChanged()
@@ -2237,11 +2386,9 @@ func (st *uiState) handleChildRename(childID, newName string) {
st.drawStatusLine() st.drawStatusLine()
} }
// handleChildClose removes a child entry entirely. For agents this is // handleChildClose removes a child entry entirely for process deletes.
// equivalent to a SIGTERM kill (the entry is ephemeral and disappears // For agent Close, it terminates the PTY with escalation but preserves
// from the session once the PTY exits). For command processes it's // the exited pane so the user can still read the corpse.
// equivalent to the MCP close_process tool: SIGKILL if alive, then
// drop the entry so it stops appearing in the switch/restart lists.
func (st *uiState) handleChildClose(childID string, kill bool) { func (st *uiState) handleChildClose(childID string, kill bool) {
if childID == "" { if childID == "" {
st.repaintFocused() st.repaintFocused()
@@ -2256,7 +2403,11 @@ func (st *uiState) handleChildClose(childID string, kill bool) {
if kill { if kill {
_ = st.sess.Close(childID, syscall.SIGKILL) _ = st.sess.Close(childID, syscall.SIGKILL)
} else { } else {
_ = st.sess.Kill(childID, syscall.SIGTERM) go func() {
if err := st.sess.Terminate(childID, syscall.SIGTERM); err != nil {
logf("terminate child %s: %v", childID, err)
}
}()
} }
st.repaintFocused() st.repaintFocused()
st.drawTabBar() st.drawTabBar()
@@ -2293,8 +2444,19 @@ func (st *uiState) handleProcRestart(childID string) {
return return
} }
layout := st.layoutSnapshot() layout := st.layoutSnapshot()
st.mu.Lock()
if c.ID == st.focusedID {
st.renderer = newViewportRenderer(layout)
st.repaintNextPTY = c.ID
st.repaintNextPTYBudget = 2
}
st.mu.Unlock()
st.repaintFocusedWithChrome()
if err := st.sess.Restart(childID, syscall.SIGTERM, layout.childCols(), layout.childRows()); err != nil { if err := st.sess.Restart(childID, syscall.SIGTERM, layout.childCols(), layout.childRows()); err != nil {
st.flashError(fmt.Sprintf("restart %s: %v", c.DisplayName(), err)) st.flashError(fmt.Sprintf("restart %s: %v", c.DisplayName(), err))
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
return return
} }
st.repaintFocused() st.repaintFocused()
@@ -2420,6 +2582,7 @@ func (st *uiState) renderPadView(name, content string, layout terminalLayout) []
st.padOffset = 0 st.padOffset = 0
} }
offset := st.padOffset offset := st.padOffset
st.view.PadOffset = offset
st.mu.Unlock() st.mu.Unlock()
var b strings.Builder var b strings.Builder
@@ -2477,6 +2640,7 @@ func (st *uiState) exitPadView() {
return return
} }
st.focusedPad = "" st.focusedPad = ""
st.view.FocusedPad = ""
st.focusedName = "" st.focusedName = ""
st.mu.Unlock() st.mu.Unlock()
st.clearViewportArea() st.clearViewportArea()
@@ -2503,6 +2667,7 @@ func (st *uiState) padScroll(delta int) {
if st.padOffset < 0 { if st.padOffset < 0 {
st.padOffset = 0 st.padOffset = 0
} }
st.view.PadOffset = st.padOffset
st.mu.Unlock() st.mu.Unlock()
st.repaintFocusedPad() st.repaintFocusedPad()
} }

View File

@@ -26,6 +26,11 @@ import (
// false positives (timestamps, exit codes, etc.). // false positives (timestamps, exit codes, etc.).
var portRegex = regexp.MustCompile(`https?://[^\s:/]+:(\d{2,5})(?:/[^\s]*)?`) var portRegex = regexp.MustCompile(`https?://[^\s:/]+:(\d{2,5})(?:/[^\s]*)?`)
const (
agentInterPieceDelay = 15 * time.Millisecond
agentSubmitSettleDelay = 100 * time.Millisecond
)
type ChildStatus string type ChildStatus string
const ( const (
@@ -223,7 +228,7 @@ func (c *Child) startPTY(cols, rows uint16) (uint64, error) {
} }
starting := StatusStarting starting := StatusStarting
c.status.Store(&starting) c.status.Store(&starting)
p, err := pkgpty.Start(c.Argv, c.Env, cols, rows) p, err := pkgpty.Start(c.Argv, c.Env, c.WorkDir, cols, rows)
if err != nil { if err != nil {
em.Close() em.Close()
errored := StatusErrored errored := StatusErrored
@@ -625,25 +630,25 @@ func (c *Child) InjectAsOrchestrator(b []byte) error {
} }
// writeInput is the shared PTY write path used by both injection // writeInput is the shared PTY write path used by both injection
// flavours. Each Enter byte (CR or LF) is split onto its own write // flavours. Agent panes split each Enter byte (CR or LF) onto its own
// with a brief delay so TUI agents with paste-detection (claude, // write with a brief delay so TUI agents with paste-detection (claude,
// codex, opencode) don't coalesce a trailing CR into the text that // codex, opencode) don't coalesce a trailing CR into the text that
// preceded it. Without the split, `pty.Write([]byte("hello\r"))` // preceded it. Raw terminals and command panes receive the original
// arrives at the agent as one read() and gets treated as multi-line // byte stream in one write; otherwise a multiline paste pays the agent
// pasted content rather than "key Enter". // workaround's delay once per line.
func (c *Child) writeInput(b []byte) error { func (c *Child) writeInput(b []byte) error {
pty := c.PTY() pty := c.PTY()
if pty == nil { if pty == nil {
return errors.New("child has no pty") return errors.New("child has no pty")
} }
pieces := splitOnEnter(b) pieces := inputWritePieces(c.Kind, b)
if len(pieces) <= 1 { if len(pieces) <= 1 {
_, err := pty.Write(b) _, err := pty.Write(b)
return err return err
} }
for i, piece := range pieces { for i, piece := range pieces {
if i > 0 { if delay := pieceWriteDelay(i, len(pieces), piece); delay > 0 {
time.Sleep(15 * time.Millisecond) time.Sleep(delay)
} }
if _, err := pty.Write(piece); err != nil { if _, err := pty.Write(piece); err != nil {
return err return err
@@ -652,6 +657,27 @@ func (c *Child) writeInput(b []byte) error {
return nil return nil
} }
func inputWritePieces(kind ChildKind, b []byte) [][]byte {
if kind != KindAgent {
return [][]byte{b}
}
return splitOnEnter(b)
}
func pieceWriteDelay(index, total int, piece []byte) time.Duration {
if index == 0 {
return 0
}
if index == total-1 && isLoneEnter(piece) {
return agentSubmitSettleDelay
}
return agentInterPieceDelay
}
func isLoneEnter(piece []byte) bool {
return len(piece) == 1 && (piece[0] == '\r' || piece[0] == '\n')
}
func mintIdentity() string { func mintIdentity() string {
var buf [12]byte var buf [12]byte
_, _ = rand.Read(buf[:]) _, _ = rand.Read(buf[:])

View File

@@ -0,0 +1,90 @@
package app
import (
"bytes"
"testing"
"time"
)
func TestInputWritePiecesOnlySplitAgentEnters(t *testing.T) {
in := []byte("alpha\nbeta\rgamma")
for _, kind := range []ChildKind{KindTerminal, KindCommand} {
t.Run(string(kind), func(t *testing.T) {
got := inputWritePieces(kind, in)
if len(got) != 1 || !bytes.Equal(got[0], in) {
t.Fatalf("inputWritePieces(%s) = %#v, want one original chunk", kind, got)
}
})
}
got := inputWritePieces(KindAgent, in)
if len(got) != 5 {
t.Fatalf("agent pieces len = %d, want 5 (%#v)", len(got), got)
}
want := [][]byte{[]byte("alpha"), []byte("\n"), []byte("beta"), []byte("\r"), []byte("gamma")}
for i := range want {
if !bytes.Equal(got[i], want[i]) {
t.Fatalf("agent piece %d = %q, want %q", i, got[i], want[i])
}
}
}
func TestPieceWriteDelay(t *testing.T) {
cases := []struct {
name string
index int
total int
piece []byte
want time.Duration
}{
{
name: "first piece",
index: 0,
total: 3,
piece: []byte("body"),
want: 0,
},
{
name: "middle body piece",
index: 1,
total: 3,
piece: []byte("body"),
want: agentInterPieceDelay,
},
{
name: "final carriage return submit",
index: 1,
total: 2,
piece: []byte("\r"),
want: agentSubmitSettleDelay,
},
{
name: "final newline submit",
index: 1,
total: 2,
piece: []byte("\n"),
want: agentSubmitSettleDelay,
},
{
name: "final non-enter piece",
index: 2,
total: 3,
piece: []byte("tail"),
want: agentInterPieceDelay,
},
{
name: "standalone enter fast path",
index: 0,
total: 1,
piece: []byte("\r"),
want: 0,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := pieceWriteDelay(tc.index, tc.total, tc.piece); got != tc.want {
t.Fatalf("pieceWriteDelay(%d, %d, %q) = %s, want %s", tc.index, tc.total, tc.piece, got, tc.want)
}
})
}
}

View File

@@ -0,0 +1,78 @@
package app
import "github.com/hjbdev/patterm/internal/scratchpad"
// chromeModel is the semantic host chrome state. Renderers continue to own
// ANSI output; this model is the serializable shape a client can draw locally.
type chromeModel struct {
ProjectKey string `json:"project_key"`
FocusedID string `json:"focused_id,omitempty"`
FocusedPad string `json:"focused_pad,omitempty"`
ActiveAgentID string `json:"active_agent_id,omitempty"`
Tabs []childModel `json:"tabs"`
Processes []childModel `json:"processes"`
AgentTree []childModel `json:"agent_tree"`
Sidebar []navEntryModel `json:"sidebar"`
Scratchpads []scratchpadModel `json:"scratchpads"`
}
type childModel struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
ParentID string `json:"parent_id,omitempty"`
Status string `json:"status"`
Owner string `json:"owner"`
}
type navEntryModel struct {
ChildID string `json:"child_id,omitempty"`
Pad string `json:"pad,omitempty"`
}
type scratchpadModel struct {
Name string `json:"name"`
}
func buildChromeModel(projectKey string, view ClientView, children []*Child, pads []scratchpad.Entry) chromeModel {
active := view.ActiveAgentID
if active == "" {
active = activeRootID(children, view.FocusedID)
}
model := chromeModel{
ProjectKey: projectKey,
FocusedID: view.FocusedID,
FocusedPad: view.FocusedPad,
ActiveAgentID: active,
}
for _, c := range runningTopLevels(children) {
model.Tabs = append(model.Tabs, serializeChildModel(c))
}
for _, c := range processList(children) {
model.Processes = append(model.Processes, serializeChildModel(c))
}
for _, c := range visibleAgentTree(children, active) {
model.AgentTree = append(model.AgentTree, serializeChildModel(c))
}
for _, n := range sidebarNav(children, active, pads) {
model.Sidebar = append(model.Sidebar, navEntryModel{ChildID: n.childID, Pad: n.pad})
}
for _, p := range pads {
model.Scratchpads = append(model.Scratchpads, scratchpadModel{Name: p.Name})
}
return model
}
func serializeChildModel(c *Child) childModel {
if c == nil {
return childModel{}
}
return childModel{
ID: c.ID,
Name: c.DisplayName(),
Kind: string(c.Kind),
ParentID: c.ParentID,
Status: string(c.Status()),
Owner: string(c.Owner()),
}
}

View File

@@ -0,0 +1,24 @@
package app
import "testing"
func TestBuildChromeModelSeparatesProcessesTabsAndSidebar(t *testing.T) {
running := StatusRunning
proc := testProcess("p1", "server", running)
agent := testAgent("a1", "codex", "", running)
sub := testAgent("a2", "worker", "a1", running)
model := buildChromeModel("project", ClientView{FocusedID: "p1", ActiveAgentID: "a1"}, []*Child{proc, agent, sub}, nil)
if len(model.Tabs) != 1 || model.Tabs[0].ID != "a1" {
t.Fatalf("tabs = %#v, want only top-level agent", model.Tabs)
}
if len(model.Processes) != 1 || model.Processes[0].ID != "p1" {
t.Fatalf("processes = %#v, want process section", model.Processes)
}
if len(model.AgentTree) != 2 || model.AgentTree[0].ID != "a1" || model.AgentTree[1].ID != "a2" {
t.Fatalf("agent tree = %#v", model.AgentTree)
}
if len(model.Sidebar) != 3 || model.Sidebar[0].ChildID != "p1" || model.Sidebar[1].ChildID != "a1" {
t.Fatalf("sidebar = %#v", model.Sidebar)
}
}

View File

@@ -0,0 +1,122 @@
package app
import (
"encoding/json"
"sync"
"github.com/hjbdev/patterm/internal/protocol"
)
const defaultClientSubscriberQueue = 256
// clientSubscriber is the daemon-to-client event bridge. Unlike daemon-local
// listeners such as timers, debug capture, and waiters, it never blocks the PTY
// pump: PTY chunks are copied before enqueue, and overflow marks the pane as
// needing a fresh snapshot.
type clientSubscriber struct {
projectKey string
frames chan protocol.Frame
mu sync.Mutex
snapshotRequired map[string]bool
lifecycleDirty bool
}
func newClientSubscriber(projectKey string, size int) *clientSubscriber {
if size <= 0 {
size = defaultClientSubscriberQueue
}
return &clientSubscriber{
projectKey: projectKey,
frames: make(chan protocol.Frame, size),
snapshotRequired: make(map[string]bool),
lifecycleDirty: false,
}
}
func (s *clientSubscriber) Recv() (protocol.Frame, bool) {
f, ok := <-s.frames
return f, ok
}
func (s *clientSubscriber) SnapshotRequired(childID string) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.snapshotRequired[childID]
}
func (s *clientSubscriber) OnChildSpawned(c *Child) {
s.sendLifecycle(protocol.LifecycleSpawned, c, "")
}
func (s *clientSubscriber) OnChildExited(c *Child) {
s.sendLifecycle(protocol.LifecycleExited, c, "")
}
func (s *clientSubscriber) OnChildClosed(id string) {
s.sendFrame(protocol.Frame{Type: protocol.FrameLifecycle, Payload: mustJSON(protocol.Lifecycle{
Kind: protocol.LifecycleClosed,
ProjectKey: s.projectKey,
ChildID: id,
})})
}
func (s *clientSubscriber) OnChildStateChanged(id string, state IdleState) {
s.sendFrame(protocol.Frame{Type: protocol.FrameLifecycle, Payload: mustJSON(protocol.Lifecycle{
Kind: protocol.LifecycleStateChanged,
ProjectKey: s.projectKey,
ChildID: id,
State: string(state),
})})
}
func (s *clientSubscriber) OnPTYOut(childID string, chunk []byte) {
cp := append([]byte(nil), chunk...)
f, err := protocol.NewFrame(protocol.FramePaneChunk, protocol.PaneChunk{PaneID: childID, Bytes: cp})
if err != nil {
return
}
select {
case s.frames <- f:
default:
s.mu.Lock()
s.snapshotRequired[childID] = true
s.mu.Unlock()
}
}
func (s *clientSubscriber) sendLifecycle(kind protocol.LifecycleKind, c *Child, state string) {
var child json.RawMessage
if c != nil {
child = mustJSON(serializeChildModel(c))
}
childID := ""
if c != nil {
childID = c.ID
}
s.sendFrame(protocol.Frame{Type: protocol.FrameLifecycle, Payload: mustJSON(protocol.Lifecycle{
Kind: kind,
ProjectKey: s.projectKey,
ChildID: childID,
Child: child,
State: state,
})})
}
func (s *clientSubscriber) sendFrame(f protocol.Frame) {
select {
case s.frames <- f:
default:
s.mu.Lock()
s.lifecycleDirty = true
s.mu.Unlock()
}
}
func mustJSON(v any) json.RawMessage {
b, err := json.Marshal(v)
if err != nil {
return nil
}
return b
}

View File

@@ -0,0 +1,32 @@
package app
import (
"testing"
"github.com/hjbdev/patterm/internal/protocol"
)
func TestClientSubscriberCopiesChunksAndMarksSnapshotOnOverflow(t *testing.T) {
sub := newClientSubscriber("project", 1)
chunk := []byte("first")
sub.OnPTYOut("p_123456", chunk)
chunk[0] = 'X'
f, ok := sub.Recv()
if !ok {
t.Fatalf("Recv closed")
}
payload, err := protocol.Decode[protocol.PaneChunk](f)
if err != nil {
t.Fatalf("Decode: %v", err)
}
if string(payload.Bytes) != "first" {
t.Fatalf("payload retained pump buffer: %q", string(payload.Bytes))
}
sub.OnPTYOut("p_123456", []byte("queued"))
sub.OnPTYOut("p_123456", []byte("dropped"))
if !sub.SnapshotRequired("p_123456") {
t.Fatalf("overflow did not mark pane snapshot required")
}
}

View File

@@ -0,0 +1,39 @@
package app
// ClientView is the per-client UI cursor over daemon-owned project/process
// state. In loopback mode there is one view, owned by uiState; future network
// clients will each get their own copy.
type ClientView struct {
ID string
ProjectKey string
FocusedID string
FocusedPad string
ActiveAgentID string
PadOffset int
PadOffsetName string
Cols uint16
Rows uint16
}
func (v *ClientView) FocusChild(id string) {
v.FocusedID = id
v.FocusedPad = ""
}
func (v *ClientView) FocusPad(name string) {
v.FocusedID = ""
v.FocusedPad = name
if v.PadOffsetName != name {
v.PadOffset = 0
v.PadOffsetName = name
}
}
func (v *ClientView) ClearPadFocus() {
v.FocusedPad = ""
}
func (v *ClientView) Resize(cols, rows uint16) {
v.Cols = cols
v.Rows = rows
}

View File

@@ -0,0 +1,29 @@
package app
import (
"github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/persist"
"github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/scratchpad"
"github.com/hjbdev/patterm/internal/trust"
)
// headlessCore is the daemon-owned half of today's single-process app. It is
// intentionally small for the foundation phase: it groups process/project
// state while the existing loopback client still renders in-process.
type headlessCore struct {
projectDir string
projectKey string
presets preset.Set
settings settings
pads *scratchpad.Store
trustStore *trust.Store
persistStore *persist.Store
mcpSrv *mcp.Server
sess *Session
launcher *Launcher
host *toolHost
}

View File

@@ -7,6 +7,7 @@ import (
"sync" "sync"
"syscall" "syscall"
"time" "time"
"unicode"
"github.com/hjbdev/patterm/internal/mcp" "github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/preset" "github.com/hjbdev/patterm/internal/preset"
@@ -398,7 +399,7 @@ func (h *toolHost) GetProcessOutput(callerID, processID, mode string, sinceOffse
if c.Kind == KindAgent { if c.Kind == KindAgent {
txt = applyChromeTrim(txt, h.chromeHintsFor(c.PresetRef)) txt = applyChromeTrim(txt, h.chromeHintsFor(c.PresetRef))
} }
out.Content = txt out.Content = normalizeGridText(txt)
return out, nil return out, nil
case "stream": case "stream":
b, end := c.StreamRead(sinceOffset) b, end := c.StreamRead(sinceOffset)
@@ -832,6 +833,14 @@ func (h *toolHost) ScratchpadAppend(name, content string) error {
return err return err
} }
func (h *toolHost) ScratchpadDelete(name string) error {
err := h.pads.Delete(name)
if err == nil && h.scratch != nil {
h.scratch.scratchpadsChanged()
}
return err
}
func (h *toolHost) WhoAmI(callerID string) mcp.WhoAmI { func (h *toolHost) WhoAmI(callerID string) mcp.WhoAmI {
w := mcp.WhoAmI{ w := mcp.WhoAmI{
ProcessID: callerID, ProcessID: callerID,
@@ -1010,6 +1019,30 @@ func stripANSI(s string) string {
return ansiRegexp.ReplaceAllString(s, "") return ansiRegexp.ReplaceAllString(s, "")
} }
func normalizeGridText(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
lines := strings.Split(s, "\n")
out := make([]string, 0, len(lines))
pendingBlank := false
for _, line := range lines {
line = strings.TrimRightFunc(line, unicode.IsSpace)
if line == "" {
if len(out) > 0 {
pendingBlank = true
}
continue
}
if pendingBlank {
out = append(out, "")
pendingBlank = false
}
out = append(out, line)
}
return strings.Join(out, "\n")
}
// stripANSIBytes is the byte-slice form of stripANSI. Skips the // stripANSIBytes is the byte-slice form of stripANSI. Skips the
// string conversion and the regex DFA — useful when the caller will // string conversion and the regex DFA — useful when the caller will
// itself walk the result line-by-line (SearchOutput) or feed it to a // itself walk the result line-by-line (SearchOutput) or feed it to a
@@ -1091,7 +1124,7 @@ func availableToolsForRole(role mcp.CallerRole) []string {
"send_input", "send_message", "request_human_attention", "send_input", "send_message", "request_human_attention",
"timer_wait", "timer_set", "timer_fire_when_idle_any", "timer_fire_when_idle_all", "timer_wait", "timer_set", "timer_fire_when_idle_any", "timer_fire_when_idle_all",
"timer_cancel", "timer_pause", "timer_resume", "timer_list", "timer_cancel", "timer_pause", "timer_resume", "timer_list",
"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append", "scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append", "scratchpad_delete",
"whoami", "help", "whoami", "help",
} }
if role == mcp.RoleOrchestrator { if role == mcp.RoleOrchestrator {
@@ -1146,8 +1179,8 @@ func helpFor(topic string) mcp.HelpResponse {
case "scratchpads": case "scratchpads":
return mcp.HelpResponse{ return mcp.HelpResponse{
Topic: "scratchpads", Topic: "scratchpads",
Content: "Project-scoped markdown files. Read returns content + revision; pass that back as expected_revision on write to get last-write-wins-with-detection. Append is unconditional.", Content: "Project-scoped markdown files. Read returns content + revision; pass that back as expected_revision on write to get last-write-wins-with-detection. Append is unconditional; delete removes a pad by name.",
RelatedTools: []string{"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append"}, RelatedTools: []string{"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append", "scratchpad_delete"},
} }
case "timers": case "timers":
return mcp.HelpResponse{ return mcp.HelpResponse{

View File

@@ -57,6 +57,21 @@ func TestClassifyTitleStability(t *testing.T) {
} }
} }
func TestClassifyTitleStabilityThinkingPatternOverridesIdle(t *testing.T) {
cfg := &resolvedIdleDetection{
strategy: StrategyOSCTitleStability,
idleThresholdMS: 2000,
thinkingRegexes: []*regexp.Regexp{mustCompile(t, `(?i)esc to interrupt`)},
}
screen := []byte("• Working (5s • esc to interrupt)")
if got, _ := classify(cfg, false, false, 9999, 5000, "codex", nil, screen); got != StateThinking {
t.Fatalf("thinking screen marker: got %q want %q", got, StateThinking)
}
if got, _ := classify(cfg, false, false, 9999, 5000, "codex", nil, []byte(">_")); got != StateIdle {
t.Fatalf("stable title without marker: got %q want %q", got, StateIdle)
}
}
func TestClassifyTitleStatus(t *testing.T) { func TestClassifyTitleStatus(t *testing.T) {
cfg := &resolvedIdleDetection{ cfg := &resolvedIdleDetection{
strategy: StrategyOSCTitleStatus, strategy: StrategyOSCTitleStatus,

View File

@@ -267,9 +267,18 @@ func (p *paletteState) buildItems(macro string) []paletteItem {
out = append(out, out = append(out,
paletteItem{label: "Rename", hint: "rename agent · " + name, paletteItem{label: "Rename", hint: "rename agent · " + name,
action: paletteAction{kind: "agent-rename-form", childID: c.ID}, group: groupFocused}, action: paletteAction{kind: "agent-rename-form", childID: c.ID}, group: groupFocused},
paletteItem{label: "Close", hint: "close agent · " + name + " (SIGTERM)", paletteItem{label: "Close", hint: "close agent · " + name + " (SIGTERM, escalates)",
action: paletteAction{kind: "agent-close", childID: c.ID}, group: groupFocused}, action: paletteAction{kind: "agent-close", childID: c.ID}, group: groupFocused},
) )
case KindTerminal:
out = append(out,
paletteItem{label: "Rename", hint: "rename terminal · " + name,
action: paletteAction{kind: "proc-rename-form", childID: c.ID}, group: groupFocused},
paletteItem{label: "Close", hint: "close terminal · " + name + " (SIGTERM)",
action: paletteAction{kind: "proc-stop", childID: c.ID}, group: groupFocused},
paletteItem{label: "Restart", hint: "restart terminal · " + name,
action: paletteAction{kind: "proc-restart", childID: c.ID}, group: groupFocused},
)
default: default:
out = append(out, out = append(out,
paletteItem{label: "Rename", hint: "rename process · " + name, paletteItem{label: "Rename", hint: "rename process · " + name,

View File

@@ -83,6 +83,25 @@ func TestContextItemsProcess(t *testing.T) {
} }
} }
func TestContextItemsTerminalUsesCloseNotStop(t *testing.T) {
c := makeFakeChild("tid", "terminal", KindTerminal)
p := newPalette([]*Child{c}, "tid", "", preset.Set{})
if _, it := findItem(p, "proc-stop"); it == nil || it.label != "Close" {
t.Fatalf("terminal close row missing or mislabelled: %+v", it)
}
if _, it := findItem(p, "proc-restart"); it == nil {
t.Fatalf("terminal restart row missing")
}
if i, _ := findItem(p, "proc-delete"); i != -1 {
t.Fatalf("terminal should not show a separate delete/close row, found at %d", i)
}
for i, it := range p.items {
if it.label == "Stop" {
t.Fatalf("terminal should not show Stop row, found at %d", i)
}
}
}
func TestContextItemsAppearAboveSwitch(t *testing.T) { func TestContextItemsAppearAboveSwitch(t *testing.T) {
// Two children so there's still a non-focused switch entry to compare // Two children so there's still a non-focused switch entry to compare
// against (the focused child is suppressed from the Open section). // against (the focused child is suppressed from the Open section).

View File

@@ -104,3 +104,44 @@ func TestStripANSIBytesEquivalence(t *testing.T) {
} }
} }
} }
func TestNormalizeGridText(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{
name: "line endings",
in: "one\r\ntwo\rthree",
want: "one\ntwo\nthree",
},
{
name: "trailing whitespace",
in: "one \ntwo\t\t\nthree",
want: "one\ntwo\nthree",
},
{
name: "collapse blank runs",
in: "one\n\n\n two\n \n\t\nthree",
want: "one\n\n two\n\nthree",
},
{
name: "trim leading and trailing blanks",
in: "\n \n\t\none\n\n",
want: "one",
},
{
name: "already clean",
in: "one\n\ntwo\nthree",
want: "one\n\ntwo\nthree",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := normalizeGridText(tc.in); got != tc.want {
t.Fatalf("normalizeGridText(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}

View File

@@ -0,0 +1,137 @@
package app
import (
"errors"
"io"
"os"
"testing"
"github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/scratchpad"
)
func silenceStdout(t *testing.T) {
t.Helper()
old := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe stdout: %v", err)
}
done := make(chan struct{})
go func() {
_, _ = io.Copy(io.Discard, r)
close(done)
}()
os.Stdout = w
t.Cleanup(func() {
os.Stdout = old
_ = w.Close()
<-done
_ = r.Close()
})
}
func newScratchpadDeleteTestState(t *testing.T) (*uiState, *scratchpad.Store) {
t.Helper()
t.Setenv("XDG_DATA_HOME", t.TempDir())
pads, err := scratchpad.Open("scratchpad-delete-test")
if err != nil {
t.Fatalf("scratchpad.Open: %v", err)
}
sess := NewSession(t.TempDir(), "scratchpad-delete-test")
t.Cleanup(sess.Shutdown)
st := &uiState{
sess: sess,
pads: pads,
hostCols: 120,
hostRows: 40,
chromeWake: make(chan struct{}, 1),
}
return st, pads
}
func TestDeletingFocusedScratchpadFocusesAnotherPad(t *testing.T) {
silenceStdout(t)
st, pads := newScratchpadDeleteTestState(t)
if _, err := pads.Write("alpha.md", "alpha", ""); err != nil {
t.Fatalf("write alpha: %v", err)
}
if _, err := pads.Write("beta.md", "beta", ""); err != nil {
t.Fatalf("write beta: %v", err)
}
st.focusedPad = "alpha.md"
st.focusedName = "alpha.md"
st.padOffsetName = "alpha.md"
st.padOffset = 3
st.handlePadDelete("alpha.md")
if st.focusedPad != "beta.md" {
t.Fatalf("focusedPad = %q, want beta.md", st.focusedPad)
}
if st.focusedID != "" {
t.Fatalf("focusedID = %q, want empty while another pad is focused", st.focusedID)
}
if st.padOffset != 0 || st.padOffsetName != "beta.md" {
t.Fatalf("pad offset = (%q,%d), want (beta.md,0)", st.padOffsetName, st.padOffset)
}
}
func TestDeletingLastFocusedScratchpadFocusesRunningChild(t *testing.T) {
silenceStdout(t)
st, pads := newScratchpadDeleteTestState(t)
if _, err := pads.Write("only.md", "only", ""); err != nil {
t.Fatalf("write only: %v", err)
}
child := makeFakeChild("pid", "devserver", KindCommand)
addChild(st.sess, child)
st.focusedPad = "only.md"
st.focusedName = "only.md"
st.handlePadDelete("only.md")
if st.focusedPad != "" {
t.Fatalf("focusedPad = %q, want empty after falling back to child", st.focusedPad)
}
if st.focusedID != "pid" {
t.Fatalf("focusedID = %q, want pid", st.focusedID)
}
}
type scratchpadChangeRecorder struct {
count int
}
func (r *scratchpadChangeRecorder) scratchpadsChanged() {
r.count++
}
func TestToolHostScratchpadDeleteRemovesPadAndRefreshes(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
pads, err := scratchpad.Open("scratchpad-delete-host-test")
if err != nil {
t.Fatalf("scratchpad.Open: %v", err)
}
if _, err := pads.Write("doomed.md", "content", ""); err != nil {
t.Fatalf("write doomed.md: %v", err)
}
recorder := &scratchpadChangeRecorder{}
host := newToolHost(nil, pads, nil, preset.Set{}, nil, 120, 40)
host.scratch = recorder
if err := host.ScratchpadDelete("doomed.md"); err != nil {
t.Fatalf("ScratchpadDelete: %v", err)
}
if recorder.count != 1 {
t.Fatalf("scratchpadsChanged calls = %d, want 1", recorder.count)
}
if _, _, err := pads.Read("doomed.md"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("read deleted pad error = %v, want os.ErrNotExist", err)
}
if err := host.ScratchpadDelete("doomed.md"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("delete missing error = %v, want os.ErrNotExist", err)
}
if recorder.count != 1 {
t.Fatalf("scratchpadsChanged calls after failed delete = %d, want 1", recorder.count)
}
}

View File

@@ -46,6 +46,13 @@ type Session struct {
listenersMu sync.Mutex listenersMu sync.Mutex
listeners atomic.Pointer[[]ChildEventListener] listeners atomic.Pointer[[]ChildEventListener]
// clientListeners is the network-client subscriber path. These
// listeners must be non-blocking and copy PTY chunks before enqueueing;
// daemon-internal observers (timers, debug capture, waiters) stay on
// listeners above so backpressure policy is isolated to clients.
clientListenersMu sync.Mutex
clientListeners atomic.Pointer[[]ChildEventListener]
// persistStore records top-level command entries to a per-project // persistStore records top-level command entries to a per-project
// JSON file so they can be re-spawned after patterm restarts. // JSON file so they can be re-spawned after patterm restarts.
// Optional; nil means "no persistence" (used by unit tests). // Optional; nil means "no persistence" (used by unit tests).
@@ -118,6 +125,16 @@ func (s *Session) Subscribe(l ChildEventListener) {
s.listeners.Store(&next) s.listeners.Store(&next)
} }
func (s *Session) SubscribeClient(l ChildEventListener) {
s.clientListenersMu.Lock()
defer s.clientListenersMu.Unlock()
prev := s.clientListenersSnapshot()
next := make([]ChildEventListener, 0, len(prev)+1)
next = append(next, prev...)
next = append(next, l)
s.clientListeners.Store(&next)
}
// Unsubscribe removes a previously-registered listener. Safe to call // Unsubscribe removes a previously-registered listener. Safe to call
// with a listener that wasn't registered (no-op). // with a listener that wasn't registered (no-op).
func (s *Session) Unsubscribe(l ChildEventListener) { func (s *Session) Unsubscribe(l ChildEventListener) {
@@ -146,16 +163,30 @@ func (s *Session) listenersSnapshot() []ChildEventListener {
return *p return *p
} }
func (s *Session) clientListenersSnapshot() []ChildEventListener {
p := s.clientListeners.Load()
if p == nil {
return nil
}
return *p
}
func (s *Session) emitSpawn(c *Child) { func (s *Session) emitSpawn(c *Child) {
for _, l := range s.listenersSnapshot() { for _, l := range s.listenersSnapshot() {
l.OnChildSpawned(c) l.OnChildSpawned(c)
} }
for _, l := range s.clientListenersSnapshot() {
l.OnChildSpawned(c)
}
} }
func (s *Session) emitExit(c *Child) { func (s *Session) emitExit(c *Child) {
for _, l := range s.listenersSnapshot() { for _, l := range s.listenersSnapshot() {
l.OnChildExited(c) l.OnChildExited(c)
} }
for _, l := range s.clientListenersSnapshot() {
l.OnChildExited(c)
}
} }
// emitPTYOut dispatches a fresh PTY chunk to every listener. Listeners // emitPTYOut dispatches a fresh PTY chunk to every listener. Listeners
@@ -165,18 +196,27 @@ func (s *Session) emitPTYOut(id string, chunk []byte) {
for _, l := range s.listenersSnapshot() { for _, l := range s.listenersSnapshot() {
l.OnPTYOut(id, chunk) l.OnPTYOut(id, chunk)
} }
for _, l := range s.clientListenersSnapshot() {
l.OnPTYOut(id, chunk)
}
} }
func (s *Session) emitStateChanged(id string, state IdleState) { func (s *Session) emitStateChanged(id string, state IdleState) {
for _, l := range s.listenersSnapshot() { for _, l := range s.listenersSnapshot() {
l.OnChildStateChanged(id, state) l.OnChildStateChanged(id, state)
} }
for _, l := range s.clientListenersSnapshot() {
l.OnChildStateChanged(id, state)
}
} }
func (s *Session) emitClosed(id string) { func (s *Session) emitClosed(id string) {
for _, l := range s.listenersSnapshot() { for _, l := range s.listenersSnapshot() {
l.OnChildClosed(id) l.OnChildClosed(id)
} }
for _, l := range s.clientListenersSnapshot() {
l.OnChildClosed(id)
}
} }
func (s *Session) ChildEnv() []string { func (s *Session) ChildEnv() []string {
@@ -395,6 +435,20 @@ func (s *Session) Close(id string, sig syscall.Signal) error {
return nil return nil
} }
// Terminate stops a live child with SIGTERM/SIGKILL escalation but
// leaves its session entry intact so callers can keep showing the
// exited pane.
func (s *Session) Terminate(id string, sig syscall.Signal) error {
c := s.FindChild(id)
if c == nil {
return fmt.Errorf("no such process %q", id)
}
if c.IsLive() {
terminateAndWait(c, sig, childStopTimeout)
}
return nil
}
// mintUniqueIDLocked mints an opaque process_id (SPEC §7) and retries // mintUniqueIDLocked mints an opaque process_id (SPEC §7) and retries
// if it collides with an existing entry. Caller holds s.mu. // if it collides with an existing entry. Caller holds s.mu.
func (s *Session) mintUniqueIDLocked() string { func (s *Session) mintUniqueIDLocked() string {

View File

@@ -1,6 +1,7 @@
package app package app
import ( import (
"strings"
"syscall" "syscall"
"testing" "testing"
"time" "time"
@@ -101,6 +102,50 @@ func TestSpawnInstallsIdleDetectionBeforePublish(t *testing.T) {
} }
} }
func TestTerminateEscalatesWithoutRemovingEntry(t *testing.T) {
sess := NewSession(t.TempDir(), "test")
c, err := sess.Spawn(SpawnSpec{
Kind: KindAgent,
Argv: []string{"sh", "-c", "trap '' TERM; echo ready; while :; do sleep 1; done"},
}, 80, 24)
if err != nil {
t.Fatalf("spawn: %v", err)
}
t.Cleanup(func() {
if c.IsLive() {
_ = c.signal(syscall.SIGKILL)
}
})
waitUntilLive(t, c)
waitForStreamText(t, c, "ready")
start := time.Now()
if err := sess.Terminate(c.ID, syscall.SIGTERM); err != nil {
t.Fatalf("Terminate: %v", err)
}
if elapsed := time.Since(start); elapsed < childStopTimeout {
t.Fatalf("Terminate returned before SIGKILL fallback: elapsed=%s timeout=%s", elapsed, childStopTimeout)
}
waitUntilNotLive(t, c)
if got := sess.FindChild(c.ID); got == nil {
t.Fatalf("Terminate removed child entry %s", c.ID)
}
}
func waitForStreamText(t *testing.T, c *Child, want string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
b, _ := c.StreamRead(0)
if strings.Contains(string(b), want) {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("child %s never wrote %q", c.ID, want)
}
func waitUntilLive(t *testing.T, c *Child) { func waitUntilLive(t *testing.T, c *Child) {
t.Helper() t.Helper()
deadline := time.Now().Add(5 * time.Second) deadline := time.Now().Add(5 * time.Second)

View File

@@ -52,6 +52,41 @@ func TestWrapSidebarSummaryKeepsWordBoundaries(t *testing.T) {
} }
} }
func TestSummaryTextForSelectsChildAndClips(t *testing.T) {
sess := NewSession(t.TempDir(), "test")
cfg := defaultSettings()
st := &uiState{
sess: sess,
settings: cfg,
summaries: newSummaryManager(sess, t.TempDir(), preset.Set{}, func() autoSummarySettings {
return cfg.AutoSummary.clone()
}, nil, nil),
}
st.summaries.mu.Lock()
st.summaries.entries["a1"] = &summaryEntry{state: summaryState{Text: " alpha summary "}}
st.summaries.entries["a2"] = &summaryEntry{state: summaryState{Text: "beta summary"}}
st.summaries.entries["empty"] = &summaryEntry{state: summaryState{Text: " "}}
st.summaries.entries["long"] = &summaryEntry{state: summaryState{Text: "abcdefghijklmnopqrstuvwxyz"}}
st.summaries.mu.Unlock()
if got := st.summaryTextFor("a2", 20); got != "beta summary" {
t.Fatalf("summaryTextFor(a2) = %q, want beta summary", got)
}
if got := st.summaryTextFor("empty", 20); got != "" {
t.Fatalf("summaryTextFor(empty) = %q, want empty", got)
}
if got := st.summaryTextFor("long", 8); got != "abcdefg…" {
t.Fatalf("summaryTextFor(long) = %q, want abcdefg…", got)
}
st.settingsMu.Lock()
st.settings.AutoSummary.Enabled = false
st.settingsMu.Unlock()
if got := st.summaryTextFor("a1", 20); got != "" {
t.Fatalf("summaryTextFor disabled = %q, want empty", got)
}
}
func TestSummaryManagerArmsOnlyTrackedTopLevelAgents(t *testing.T) { func TestSummaryManagerArmsOnlyTrackedTopLevelAgents(t *testing.T) {
sess := NewSession(t.TempDir(), "test") sess := NewSession(t.TempDir(), "test")
c := newChildEntry("a1", "agent", KindAgent, []string{"fake"}, nil, "", "", "") c := newChildEntry("a1", "agent", KindAgent, []string{"fake"}, nil, "", "", "")

View File

@@ -59,6 +59,7 @@ func (st *uiState) drawTabBar() {
newHintW := utf8.RuneCountInString(newHint) + 2 // " + new " framing newHintW := utf8.RuneCountInString(newHint) + 2 // " + new " framing
type tabRect struct { type tabRect struct {
childID string
startCol int startCol int
width int width int
label string label string
@@ -66,8 +67,6 @@ func (st *uiState) drawTabBar() {
glyphStyle string glyphStyle string
active bool active bool
} }
activeTab := -1
// Reserve space at the right edge for "+ new". If there are too // Reserve space at the right edge for "+ new". If there are too
// many tabs to fit even at minTabWidth, drop tabs from the right // many tabs to fit even at minTabWidth, drop tabs from the right
// until they do. The current focus stays visible. // until they do. The current focus stays visible.
@@ -139,6 +138,7 @@ func (st *uiState) drawTabBar() {
labelW = utf8.RuneCountInString(label) labelW = utf8.RuneCountInString(label)
} }
tabs = append(tabs, tabRect{ tabs = append(tabs, tabRect{
childID: c.ID,
startCol: col, startCol: col,
width: w, width: w,
label: label, label: label,
@@ -146,9 +146,6 @@ func (st *uiState) drawTabBar() {
glyphStyle: glyphStyle, glyphStyle: glyphStyle,
active: active, active: active,
}) })
if tabs[len(tabs)-1].active {
activeTab = len(tabs) - 1
}
col += w col += w
} }
} }
@@ -224,10 +221,9 @@ func (st *uiState) drawTabBar() {
hintCol, styleBorder, strings.Repeat("─", newHintW), styleReset) hintCol, styleBorder, strings.Repeat("─", newHintW), styleReset)
} }
if activeTab >= 0 { for _, tab := range tabs {
tab := tabs[activeTab]
summaryWidth := tab.width - 2 summaryWidth := tab.width - 2
if summary := st.activeSummaryText(summaryWidth); summary != "" { if summary := st.summaryTextFor(tab.childID, summaryWidth); summary != "" {
fmt.Fprintf(&b, "\x1b[2;%dH %s%s%s", tab.startCol, styleDim, summary, styleReset) fmt.Fprintf(&b, "\x1b[2;%dH %s%s%s", tab.startCol, styleDim, summary, styleReset)
} }
} }

View File

@@ -58,6 +58,7 @@ type timerManager struct {
mu sync.Mutex mu sync.Mutex
nextID int nextID int
timers map[string]*pendingTimer timers map[string]*pendingTimer
changes chan struct{}
// fireFn is the callback used to deliver the body to the owning // fireFn is the callback used to deliver the body to the owning
// process. Decoupled so tests can substitute a recorder. Defaults // process. Decoupled so tests can substitute a recorder. Defaults
@@ -69,11 +70,23 @@ func newTimerManager(sess *Session) *timerManager {
m := &timerManager{ m := &timerManager{
sess: sess, sess: sess,
timers: make(map[string]*pendingTimer), timers: make(map[string]*pendingTimer),
changes: make(chan struct{}, 1),
} }
m.fireFn = defaultFireFn m.fireFn = defaultFireFn
return m return m
} }
func (m *timerManager) changeEvents() <-chan struct{} {
return m.changes
}
func (m *timerManager) notifyChanged() {
select {
case m.changes <- struct{}{}:
default:
}
}
func defaultFireFn(owner *Child, body, label string) { func defaultFireFn(owner *Child, body, label string) {
if owner == nil || !owner.IsLive() { if owner == nil || !owner.IsLive() {
return return
@@ -121,6 +134,7 @@ func (m *timerManager) TimerSet(ownerID string, body, label string, seconds floa
m.timers[id] = t m.timers[id] = t
m.mu.Unlock() m.mu.Unlock()
t.rt = time.AfterFunc(d, func() { m.fireDelay(id) }) t.rt = time.AfterFunc(d, func() { m.fireDelay(id) })
m.notifyChanged()
return id, nil return id, nil
} }
@@ -136,6 +150,7 @@ func (m *timerManager) fireDelay(id string) {
body, label := t.body, t.label body, label := t.body, t.label
delete(m.timers, id) delete(m.timers, id)
m.mu.Unlock() m.mu.Unlock()
m.notifyChanged()
m.fireFn(owner, body, label) m.fireFn(owner, body, label)
} }
@@ -214,6 +229,7 @@ func (m *timerManager) registerIdleTimer(kind pendingTimerKind, ownerID, body, l
} }
m.timers[id] = t m.timers[id] = t
m.mu.Unlock() m.mu.Unlock()
m.notifyChanged()
resp.ID = id resp.ID = id
resp.Status = "pending" resp.Status = "pending"
return resp, nil return resp, nil
@@ -231,6 +247,7 @@ func (m *timerManager) fireIdleMaxWait(id string) {
body, label := t.body, t.label body, label := t.body, t.label
delete(m.timers, id) delete(m.timers, id)
m.mu.Unlock() m.mu.Unlock()
m.notifyChanged()
m.fireFn(owner, body, label) m.fireFn(owner, body, label)
} }
@@ -291,6 +308,9 @@ func (m *timerManager) onChildStateChanged(childID string, state IdleState) {
delete(m.timers, id) delete(m.timers, id)
} }
m.mu.Unlock() m.mu.Unlock()
if len(firedIDs) > 0 {
m.notifyChanged()
}
for _, f := range fires { for _, f := range fires {
m.fireFn(f.owner, f.body, f.label) m.fireFn(f.owner, f.body, f.label)
} }
@@ -320,7 +340,7 @@ func (m *timerManager) onChildStateChanged(childID string, state IdleState) {
// legitimate fire and leave the parent never notified. // legitimate fire and leave the parent never notified.
func (m *timerManager) onChildClosed(childID string) { func (m *timerManager) onChildClosed(childID string) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() changed := false
for id, t := range m.timers { for id, t := range m.timers {
if t.ownerID == childID { if t.ownerID == childID {
if t.rt != nil { if t.rt != nil {
@@ -329,6 +349,7 @@ func (m *timerManager) onChildClosed(childID string) {
} }
t.status = timerStatusCanceled t.status = timerStatusCanceled
delete(m.timers, id) delete(m.timers, id)
changed = true
continue continue
} }
if !contains(t.watched, childID) { if !contains(t.watched, childID) {
@@ -344,6 +365,7 @@ func (m *timerManager) onChildClosed(childID string) {
if t.idleBaseline != nil { if t.idleBaseline != nil {
delete(t.idleBaseline, childID) delete(t.idleBaseline, childID)
} }
changed = true
if len(t.watched) == 0 { if len(t.watched) == 0 {
if t.rt != nil { if t.rt != nil {
t.rt.Stop() t.rt.Stop()
@@ -353,6 +375,10 @@ func (m *timerManager) onChildClosed(childID string) {
delete(m.timers, id) delete(m.timers, id)
} }
} }
m.mu.Unlock()
if changed {
m.notifyChanged()
}
} }
// allWatchedIdleLocked reports whether every watched child is now // allWatchedIdleLocked reports whether every watched child is now
@@ -374,19 +400,21 @@ func (m *timerManager) allWatchedIdleLocked(t *pendingTimer) bool {
// TimerCancel removes a pending or paused timer owned by ownerID. // TimerCancel removes a pending or paused timer owned by ownerID.
func (m *timerManager) TimerCancel(ownerID, id string) error { func (m *timerManager) TimerCancel(ownerID, id string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock()
t, ok := m.timers[id] t, ok := m.timers[id]
if !ok { if !ok {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id) return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id)
} }
// Empty ownerID = top-level orchestrator caller (e.g. a non-agent // Empty ownerID = top-level orchestrator caller (e.g. a non-agent
// MCP client); allow it to manage every timer in the session. // MCP client); allow it to manage every timer in the session.
// Otherwise the caller's own id must match the timer's owner. // Otherwise the caller's own id must match the timer's owner.
if ownerID != "" && t.ownerID != ownerID { if ownerID != "" && t.ownerID != ownerID {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id) return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id)
} }
if t.status == timerStatusFired || t.status == timerStatusCanceled { if t.status == timerStatusFired || t.status == timerStatusCanceled {
// Cancelling a fired/cancelled timer is idempotent. // Cancelling a fired/cancelled timer is idempotent.
m.mu.Unlock()
return nil return nil
} }
if t.rt != nil { if t.rt != nil {
@@ -395,6 +423,8 @@ func (m *timerManager) TimerCancel(ownerID, id string) error {
} }
t.status = timerStatusCanceled t.status = timerStatusCanceled
delete(m.timers, id) delete(m.timers, id)
m.mu.Unlock()
m.notifyChanged()
return nil return nil
} }
@@ -402,18 +432,20 @@ func (m *timerManager) TimerCancel(ownerID, id string) error {
// keeps the timer in the registry. // keeps the timer in the registry.
func (m *timerManager) TimerPause(ownerID, id string) error { func (m *timerManager) TimerPause(ownerID, id string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock()
t, ok := m.timers[id] t, ok := m.timers[id]
if !ok { if !ok {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id) return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id)
} }
// Empty ownerID = top-level orchestrator caller (e.g. a non-agent // Empty ownerID = top-level orchestrator caller (e.g. a non-agent
// MCP client); allow it to manage every timer in the session. // MCP client); allow it to manage every timer in the session.
// Otherwise the caller's own id must match the timer's owner. // Otherwise the caller's own id must match the timer's owner.
if ownerID != "" && t.ownerID != ownerID { if ownerID != "" && t.ownerID != ownerID {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id) return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id)
} }
if t.status != timerStatusPending { if t.status != timerStatusPending {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindInvalidArgs, "timer %q is not pending", id) return mcp.Errorf(mcp.ErrorKindInvalidArgs, "timer %q is not pending", id)
} }
if t.rt != nil { if t.rt != nil {
@@ -429,6 +461,8 @@ func (m *timerManager) TimerPause(ownerID, id string) error {
t.pausedWasMaxWait = t.kind != timerKindDelay t.pausedWasMaxWait = t.kind != timerKindDelay
} }
t.status = timerStatusPaused t.status = timerStatusPaused
m.mu.Unlock()
m.notifyChanged()
return nil return nil
} }
@@ -507,6 +541,7 @@ func (m *timerManager) TimerResume(ownerID, id string) error {
delete(m.timers, id) delete(m.timers, id)
} }
m.mu.Unlock() m.mu.Unlock()
m.notifyChanged()
if fireNow { if fireNow {
m.fireFn(owner, body, label) m.fireFn(owner, body, label)
} }
@@ -587,6 +622,56 @@ func (m *timerManager) activeForChild(id string) *mcp.TimerInfo {
return &info return &info
} }
const (
timerSidebarMinRefresh = 50 * time.Millisecond
timerSidebarSubsecondRefresh = 100 * time.Millisecond
)
func nextTimerSidebarLabelChange(d time.Duration) time.Duration {
if d <= 0 {
return 0
}
if d < time.Second {
if d < timerSidebarSubsecondRefresh {
return d
}
return timerSidebarSubsecondRefresh
}
step := time.Second
if d >= time.Hour {
step = time.Hour
} else if d >= time.Minute {
step = time.Minute
}
wait := d % step
if wait <= 0 || wait < timerSidebarMinRefresh {
return timerSidebarMinRefresh
}
return wait
}
func (m *timerManager) nextSidebarRefreshAfter(now time.Time) (time.Duration, bool) {
m.mu.Lock()
defer m.mu.Unlock()
var best time.Duration
found := false
for _, t := range m.timers {
if t.status != timerStatusPending || t.firesAt.IsZero() {
continue
}
wait := nextTimerSidebarLabelChange(t.firesAt.Sub(now))
if wait <= 0 {
wait = timerSidebarMinRefresh
}
if !found || wait < best {
best = wait
found = true
}
}
return best, found
}
func isIdleState(s IdleState) bool { func isIdleState(s IdleState) bool {
return s == StateIdle return s == StateIdle
} }

View File

@@ -65,6 +65,93 @@ func newTestManager(t *testing.T) (*Session, *timerManager, *recorderFire) {
return sess, mgr, rec return sess, mgr, rec
} }
func waitTimerChange(t *testing.T, mgr *timerManager) {
t.Helper()
select {
case <-mgr.changeEvents():
case <-time.After(time.Second):
t.Fatal("timed out waiting for timer change signal")
}
}
func TestNextTimerSidebarLabelChange(t *testing.T) {
tests := []struct {
name string
d time.Duration
want time.Duration
}{
{name: "minutes", d: 2*time.Minute + 10*time.Second, want: 10 * time.Second},
{name: "minute_to_seconds", d: time.Minute + 500*time.Millisecond, want: 500 * time.Millisecond},
{name: "seconds", d: 59*time.Second + 500*time.Millisecond, want: 500 * time.Millisecond},
{name: "subsecond", d: 500 * time.Millisecond, want: timerSidebarSubsecondRefresh},
{name: "nearly_done", d: 30 * time.Millisecond, want: 30 * time.Millisecond},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := nextTimerSidebarLabelChange(tt.d); got != tt.want {
t.Fatalf("nextTimerSidebarLabelChange(%s) = %s, want %s", tt.d, got, tt.want)
}
})
}
}
func TestTimerSidebarRefreshAfterUsesSoonestActiveBoundary(t *testing.T) {
_, mgr, _ := newTestManager(t)
now := time.Unix(123, 0)
mgr.mu.Lock()
mgr.timers["slow"] = &pendingTimer{
id: "slow",
status: timerStatusPending,
firesAt: now.Add(2*time.Minute + 10*time.Second),
}
mgr.timers["fast"] = &pendingTimer{
id: "fast",
status: timerStatusPending,
firesAt: now.Add(59*time.Second + 500*time.Millisecond),
}
mgr.timers["paused"] = &pendingTimer{
id: "paused",
status: timerStatusPaused,
firesAt: now.Add(100 * time.Millisecond),
}
mgr.mu.Unlock()
got, ok := mgr.nextSidebarRefreshAfter(now)
if !ok {
t.Fatal("nextSidebarRefreshAfter did not find active timers")
}
if got != 500*time.Millisecond {
t.Fatalf("nextSidebarRefreshAfter = %s, want 500ms", got)
}
}
func TestTimerManagerSignalsChangesForSidebar(t *testing.T) {
sess, mgr, _ := newTestManager(t)
owner := fakeChild("p_owner")
addChild(sess, owner)
id, err := mgr.TimerSet("p_owner", "x", "", 60)
if err != nil {
t.Fatalf("TimerSet: %v", err)
}
waitTimerChange(t, mgr)
if err := mgr.TimerPause("p_owner", id); err != nil {
t.Fatalf("TimerPause: %v", err)
}
waitTimerChange(t, mgr)
if err := mgr.TimerResume("p_owner", id); err != nil {
t.Fatalf("TimerResume: %v", err)
}
waitTimerChange(t, mgr)
if err := mgr.TimerCancel("p_owner", id); err != nil {
t.Fatalf("TimerCancel: %v", err)
}
waitTimerChange(t, mgr)
}
func TestTimerSetDelivers(t *testing.T) { func TestTimerSetDelivers(t *testing.T) {
sess, mgr, rec := newTestManager(t) sess, mgr, rec := newTestManager(t)
c := fakeChild("p_owner") c := fakeChild("p_owner")

View File

@@ -143,7 +143,7 @@ func openSession(t *testing.T, env *testEnv, childEnv []string) *Session {
if err != nil { if err != nil {
t.Fatalf("vt emulator: %v", err) t.Fatalf("vt emulator: %v", err)
} }
p, err := pkgpty.Start([]string{env.PattermBin, "--project", env.ProjectDir}, childEnv, env.Cols, env.Rows) p, err := pkgpty.Start([]string{env.PattermBin, "--project", env.ProjectDir}, childEnv, "", env.Cols, env.Rows)
if err != nil { if err != nil {
_ = em.Close() _ = em.Close()
t.Fatalf("pty start: %v", err) t.Fatalf("pty start: %v", err)

View File

@@ -0,0 +1,32 @@
{
"name": "restart_process_keeps_chrome",
"cols": 120,
"rows": 40,
"scripts": [
{
"name": "slow-restart",
"body": "#!/bin/sh\ncount_file=\"$XDG_RUNTIME_DIR/slow-restart-count\"\nif [ -f \"$count_file\" ]; then\n n=$(cat \"$count_file\")\nelse\n n=0\nfi\nn=$((n + 1))\nprintf '%s\\n' \"$n\" > \"$count_file\"\nprintf 'SLOW READY %s\\n' \"$n\"\ntrap 'sleep 3; exit 0' TERM\nwhile true; do sleep 1; done\n"
}
],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": { "kind": "command", "argv": ["slow-restart"], "name": "slow-restart" },
"save_as": "spawned"
},
{
"type": "mcp_call",
"method": "select_process",
"params": { "process_id": "{{spawned.process_id}}" }
},
{ "type": "wait_text", "contains": "SLOW READY 1", "timeout_ms": 5000 },
{ "type": "wait_stable", "timeout_ms": 2000 },
{ "type": "assert_contains", "contains": "Processes" },
{ "type": "send_text", "text": "\u000brestart\r" },
{ "type": "wait_stable", "timeout_ms": 2000 },
{ "type": "assert_contains", "contains": "Processes" },
{ "type": "assert_contains", "contains": "slow-restart" },
{ "type": "wait_text", "contains": "SLOW READY 2", "timeout_ms": 7000 }
]
}

View File

@@ -55,7 +55,7 @@ func NewCLI(opts Options) (*Session, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
p, err := pkgpty.Start([]string{env.PattermBin, "--project", env.ProjectDir}, childEnv, env.Cols, env.Rows) p, err := pkgpty.Start([]string{env.PattermBin, "--project", env.ProjectDir}, childEnv, "", env.Cols, env.Rows)
if err != nil { if err != nil {
_ = em.Close() _ = em.Close()
return nil, err return nil, err

View File

@@ -96,10 +96,34 @@ func (s *Server) acceptLoop() {
// identity token (SPEC §10); we resolve it to a child id and stash that // identity token (SPEC §10); we resolve it to a child id and stash that
// as the caller for every subsequent tool call. // as the caller for every subsequent tool call.
func (s *Server) handleConn(conn net.Conn) { func (s *Server) handleConn(conn net.Conn) {
defer conn.Close() var writeMu sync.Mutex
var wg sync.WaitGroup
defer func() {
wg.Wait()
_ = conn.Close()
}()
r := bufio.NewReader(conn) r := bufio.NewReader(conn)
var callerID string var callerID string
writeResp := func(resp []byte) bool {
if resp == nil {
return true
}
resp = append(resp, '\n')
writeMu.Lock()
defer writeMu.Unlock()
for len(resp) > 0 {
n, err := conn.Write(resp)
if err != nil {
return false
}
if n == 0 {
return false
}
resp = resp[n:]
}
return true
}
greeting, err := r.ReadBytes('\n') greeting, err := r.ReadBytes('\n')
if err != nil { if err != nil {
@@ -115,24 +139,21 @@ func (s *Server) handleConn(conn net.Conn) {
} else { } else {
// Treat as a real request from an unknown caller. // Treat as a real request from an unknown caller.
resp := s.dispatch("", greeting) resp := s.dispatch("", greeting)
if resp != nil { if !writeResp(resp) {
resp = append(resp, '\n')
if _, werr := conn.Write(resp); werr != nil {
return return
} }
} }
}
for { for {
line, err := r.ReadBytes('\n') line, err := r.ReadBytes('\n')
if len(line) > 0 { if len(line) > 0 {
resp := s.dispatch(callerID, line) req := append([]byte(nil), line...)
if resp != nil { wg.Add(1)
resp = append(resp, '\n') go func() {
if _, werr := conn.Write(resp); werr != nil { defer wg.Done()
return resp := s.dispatch(callerID, req)
} _ = writeResp(resp)
} }()
} }
if err != nil { if err != nil {
return return

190
internal/mcp/mcp_test.go Normal file
View File

@@ -0,0 +1,190 @@
package mcp
import (
"bufio"
"encoding/json"
"fmt"
"net"
"sync"
"syscall"
"testing"
"time"
"github.com/hjbdev/patterm/internal/scratchpad"
)
func TestHandleConnDispatchesRequestsConcurrently(t *testing.T) {
serverConn, clientConn := net.Pipe()
t.Cleanup(func() { _ = clientConn.Close() })
host := &blockingToolHost{
waitEntered: make(chan struct{}),
waitRelease: make(chan struct{}),
}
s := &Server{}
s.SetHost(host)
done := make(chan struct{})
go func() {
s.handleConn(serverConn)
close(done)
}()
reader := bufio.NewReader(clientConn)
writeLine(t, clientConn, `{"patterm_identity":"ident"}`)
writeLine(t, clientConn, `{"jsonrpc":"2.0","id":1,"method":"wait_for_pattern","params":{"process_id":"p_slow","pattern":"never","timeout_seconds":300}}`)
select {
case <-host.waitEntered:
case <-time.After(time.Second):
t.Fatal("wait_for_pattern did not enter fake host")
}
writeLine(t, clientConn, `{"jsonrpc":"2.0","id":2,"method":"get_process_status","params":{"process_id":"p_fast"}}`)
fast := readJSONRPCResponse(t, clientConn, reader, time.Second)
if got := string(fast.ID); got != "2" {
t.Fatalf("first response id = %s, want 2; response=%s", got, fast.Raw)
}
if fast.Error != nil {
t.Fatalf("fast response returned error: %+v", fast.Error)
}
_ = clientConn.SetReadDeadline(time.Now().Add(50 * time.Millisecond))
if line, err := reader.ReadBytes('\n'); err == nil {
t.Fatalf("slow response arrived before release: %s", line)
}
close(host.waitRelease)
slow := readJSONRPCResponse(t, clientConn, reader, time.Second)
if got := string(slow.ID); got != "1" {
t.Fatalf("second response id = %s, want 1; response=%s", got, slow.Raw)
}
if slow.Error != nil {
t.Fatalf("slow response returned error: %+v", slow.Error)
}
_ = clientConn.Close()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("handleConn did not exit after client close")
}
}
type jsonRPCResponse struct {
Raw string
ID json.RawMessage `json:"id"`
Result map[string]any `json:"result"`
Error *jsonRPCErrorShape `json:"error"`
}
type jsonRPCErrorShape struct {
Code int `json:"code"`
Message string `json:"message"`
}
func writeLine(t *testing.T, conn net.Conn, line string) {
t.Helper()
_ = conn.SetWriteDeadline(time.Now().Add(time.Second))
if _, err := fmt.Fprintln(conn, line); err != nil {
t.Fatalf("write %s: %v", line, err)
}
}
func readJSONRPCResponse(t *testing.T, conn net.Conn, reader *bufio.Reader, timeout time.Duration) jsonRPCResponse {
t.Helper()
_ = conn.SetReadDeadline(time.Now().Add(timeout))
line, err := reader.ReadBytes('\n')
if err != nil {
t.Fatalf("read response: %v", err)
}
var resp jsonRPCResponse
resp.Raw = string(line)
if err := json.Unmarshal(line, &resp); err != nil {
t.Fatalf("parse response %s: %v", line, err)
}
return resp
}
type blockingToolHost struct {
waitEntered chan struct{}
waitRelease chan struct{}
waitOnce sync.Once
}
func (h *blockingToolHost) ResolveCallerIdentity(identity string) string { return "caller-" + identity }
func (h *blockingToolHost) CallerRole(string) CallerRole { return RoleOrchestrator }
func (h *blockingToolHost) SpawnAgent(string, SpawnAgentArgs) (ProcessInfo, error) {
return ProcessInfo{}, nil
}
func (h *blockingToolHost) SpawnProcess(string, SpawnProcessArgs) (ProcessInfo, error) {
return ProcessInfo{}, nil
}
func (h *blockingToolHost) StartProcess(string, string) (ProcessInfo, error) {
return ProcessInfo{}, nil
}
func (h *blockingToolHost) RestartProcess(string, string, syscall.Signal) (ProcessInfo, error) {
return ProcessInfo{}, nil
}
func (h *blockingToolHost) StopProcess(string, string, syscall.Signal) (ProcessInfo, error) {
return ProcessInfo{}, nil
}
func (h *blockingToolHost) CloseProcess(string, string) error { return nil }
func (h *blockingToolHost) RenameProcess(string, string, string) error { return nil }
func (h *blockingToolHost) SelectProcess(string, string) error { return nil }
func (h *blockingToolHost) ListProcesses(string, string) []ProcessInfo { return nil }
func (h *blockingToolHost) GetProcessStatus(string, string) (ProcessStatus, error) {
return ProcessStatus{ProcessInfo: ProcessInfo{ID: "p_fast", Status: "running"}}, nil
}
func (h *blockingToolHost) GetProjectStatus(string) (ProjectStatus, error) {
return ProjectStatus{}, nil
}
func (h *blockingToolHost) GetProcessOutput(string, string, string, int64) (ProcessOutput, error) {
return ProcessOutput{}, nil
}
func (h *blockingToolHost) GetProcessRawOutput(string, string, int64) (RawOutput, error) {
return RawOutput{}, nil
}
func (h *blockingToolHost) SearchOutput(string, string, string, string, int) (SearchResult, error) {
return SearchResult{}, nil
}
func (h *blockingToolHost) WaitForPattern(string, string, string, float64, string) (bool, string, error) {
h.waitOnce.Do(func() { close(h.waitEntered) })
<-h.waitRelease
return true, "matched", nil
}
func (h *blockingToolHost) GetProcessPorts(string, string) ([]PortSighting, error) {
return nil, nil
}
func (h *blockingToolHost) SendInput(string, SendInputArgs) (SendInputResult, error) {
return SendInputResult{}, nil
}
func (h *blockingToolHost) SendMessage(string, string, string) error { return nil }
func (h *blockingToolHost) RequestHumanAttention(string, string, string) error { return nil }
func (h *blockingToolHost) TimerWait(string, float64, string) (string, error) {
return "", nil
}
func (h *blockingToolHost) TimerSet(string, TimerSetArgs) (TimerHandle, error) {
return TimerHandle{}, nil
}
func (h *blockingToolHost) TimerFireWhenIdleAny(string, TimerFireWhenIdleArgs) (TimerFireWhenIdleResponse, error) {
return TimerFireWhenIdleResponse{}, nil
}
func (h *blockingToolHost) TimerFireWhenIdleAll(string, TimerFireWhenIdleArgs) (TimerFireWhenIdleResponse, error) {
return TimerFireWhenIdleResponse{}, nil
}
func (h *blockingToolHost) TimerCancel(string, string) error { return nil }
func (h *blockingToolHost) TimerPause(string, string) error { return nil }
func (h *blockingToolHost) TimerResume(string, string) error { return nil }
func (h *blockingToolHost) TimerList(string) ([]TimerInfo, error) {
return nil, nil
}
func (h *blockingToolHost) ScratchpadList() ([]scratchpad.Entry, error) { return nil, nil }
func (h *blockingToolHost) ScratchpadRead(string) (string, string, error) {
return "", "", nil
}
func (h *blockingToolHost) ScratchpadWrite(string, string, string) (string, error) {
return "", nil
}
func (h *blockingToolHost) ScratchpadAppend(string, string) error { return nil }
func (h *blockingToolHost) ScratchpadDelete(string) error { return nil }
func (h *blockingToolHost) WhoAmI(string) WhoAmI { return WhoAmI{} }
func (h *blockingToolHost) Help(string, string) HelpResponse { return HelpResponse{} }

View File

@@ -358,6 +358,13 @@ func toolCatalog() []toolDescriptor {
"content": stringProp("Text to append."), "content": stringProp("Text to append."),
}, []string{"name", "content"}), }, []string{"name", "content"}),
}, },
{
Name: "scratchpad_delete",
Description: "Delete a scratchpad entry.",
InputSchema: objectSchema(map[string]any{
"name": stringProp("Scratchpad name."),
}, []string{"name"}),
},
{ {
Name: "whoami", Name: "whoami",
Description: "Return the caller's identity, role, parent, project metadata, and available tools.", Description: "Return the caller's identity, role, parent, project metadata, and available tools.",

View File

@@ -101,6 +101,7 @@ type ToolHost interface {
ScratchpadRead(name string) (content string, revision string, err error) ScratchpadRead(name string) (content string, revision string, err error)
ScratchpadWrite(name, content, expectedRevision string) (revision string, err error) ScratchpadWrite(name, content, expectedRevision string) (revision string, err error)
ScratchpadAppend(name, content string) error ScratchpadAppend(name, content string) error
ScratchpadDelete(name string) error
// Meta. // Meta.
WhoAmI(callerID string) WhoAmI WhoAmI(callerID string) WhoAmI
@@ -776,6 +777,18 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
} }
return map[string]any{"ok": true}, 0, "", nil return map[string]any{"ok": true}, 0, "", nil
case "scratchpad_delete":
var p struct {
Name string `json:"name"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
if err := h.ScratchpadDelete(p.Name); err != nil {
return nil, codeInternal, err.Error(), nil
}
return map[string]any{"ok": true}, 0, "", nil
case "whoami": case "whoami":
return h.WhoAmI(callerID), 0, "", nil return h.WhoAmI(callerID), 0, "", nil

View File

@@ -352,7 +352,10 @@ func defaultAgentPresets() []*Preset {
"ready_signal": { "idle_ms": 1000 }, "ready_signal": { "idle_ms": 1000 },
"idle_detection": { "idle_detection": {
"strategy": "osc_title_stability", "strategy": "osc_title_stability",
"idle_threshold_ms": 2000 "idle_threshold_ms": 2000,
"thinking_patterns": [
"(?i)esc to interrupt"
]
}, },
"chrome_trim_hints": [ "chrome_trim_hints": [
"^OpenAI Codex", "^OpenAI Codex",

View File

@@ -27,6 +27,13 @@ func TestLoadUsesBuiltInDefaultsWithoutWritingConfig(t *testing.T) {
if claude.IdleDetection == nil || len(claude.IdleDetection.PermissionPatterns) == 0 { if claude.IdleDetection == nil || len(claude.IdleDetection.PermissionPatterns) == 0 {
t.Fatalf("built-in claude missing permission patterns: %+v", claude.IdleDetection) t.Fatalf("built-in claude missing permission patterns: %+v", claude.IdleDetection)
} }
codex := presetByName(set.Agents, "codex")
if codex == nil {
t.Fatal("missing built-in codex preset")
}
if codex.IdleDetection == nil || len(codex.IdleDetection.ThinkingPatterns) == 0 {
t.Fatalf("built-in codex missing thinking patterns: %+v", codex.IdleDetection)
}
} }
func TestLoadMergesUserOverlayIntoBuiltInPreset(t *testing.T) { func TestLoadMergesUserOverlayIntoBuiltInPreset(t *testing.T) {

164
internal/protocol/frame.go Normal file
View File

@@ -0,0 +1,164 @@
// Package protocol defines the daemon/client control frames shared by
// transports. It intentionally contains data shapes only; app behavior stays
// in internal/app until the headless daemon split is complete.
package protocol
import (
"encoding/json"
"fmt"
"time"
)
// FrameType identifies one protocol message kind.
type FrameType string
const (
FrameHello FrameType = "hello"
FrameAuthChallenge FrameType = "auth_challenge"
FrameAuthOK FrameType = "auth_ok"
FrameAttach FrameType = "attach"
FrameDetach FrameType = "detach"
FrameProjectList FrameType = "project_list"
FrameChrome FrameType = "chrome"
FramePaneSnapshot FrameType = "pane_snapshot"
FramePaneChunk FrameType = "pane_chunk"
FrameLifecycle FrameType = "lifecycle"
FrameAttention FrameType = "attention"
FrameTrustPrompt FrameType = "trust_prompt"
FrameInput FrameType = "input"
FrameFocus FrameType = "focus"
FrameSwitchProject FrameType = "switch_project"
FrameOpenProject FrameType = "open_project"
FramePaletteCommand FrameType = "palette_command"
FrameTrustResponse FrameType = "trust_response"
FrameResize FrameType = "resize"
)
// Frame is the transport envelope. Payload is deliberately raw JSON so
// network transports can frame without knowing every message type; loopback
// transports may pass the same bytes without JSON re-encoding.
type Frame struct {
Type FrameType `json:"type"`
RequestID string `json:"request_id,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
}
// NewFrame marshals payload into a protocol frame.
func NewFrame[T any](typ FrameType, payload T) (Frame, error) {
b, err := json.Marshal(payload)
if err != nil {
return Frame{}, fmt.Errorf("protocol: marshal %s: %w", typ, err)
}
return Frame{Type: typ, Payload: b}, nil
}
// Decode unmarshals f.Payload into v.
func Decode[T any](f Frame) (T, error) {
var v T
if len(f.Payload) == 0 {
return v, nil
}
if err := json.Unmarshal(f.Payload, &v); err != nil {
return v, fmt.Errorf("protocol: decode %s: %w", f.Type, err)
}
return v, nil
}
type Hello struct {
Version int `json:"version"`
DaemonID string `json:"daemon_id,omitempty"`
ClientID string `json:"client_id,omitempty"`
ProjectKey string `json:"project_key,omitempty"`
}
type Attach struct {
Token string `json:"token,omitempty"`
ProjectKey string `json:"project_key,omitempty"`
TermSize Size `json:"term_size"`
}
type Detach struct {
ClientID string `json:"client_id,omitempty"`
}
type Size struct {
Cols uint16 `json:"cols"`
Rows uint16 `json:"rows"`
}
type Project struct {
Key string `json:"key"`
Path string `json:"path"`
Name string `json:"name"`
LastActive time.Time `json:"last_active,omitempty"`
TabCount int `json:"tab_count"`
}
type ProjectList struct {
Projects []Project `json:"projects"`
}
type Chrome struct {
ProjectKey string `json:"project_key"`
Model json.RawMessage `json:"model"`
}
type PaneSnapshot struct {
PaneID string `json:"pane_id"`
Bytes []byte `json:"bytes"`
}
type PaneChunk struct {
PaneID string `json:"pane_id"`
Bytes []byte `json:"bytes"`
}
type LifecycleKind string
const (
LifecycleSpawned LifecycleKind = "spawned"
LifecycleExited LifecycleKind = "exited"
LifecycleClosed LifecycleKind = "closed"
LifecycleStateChanged LifecycleKind = "state_changed"
)
type Lifecycle struct {
Kind LifecycleKind `json:"kind"`
ProjectKey string `json:"project_key,omitempty"`
ChildID string `json:"child_id,omitempty"`
Child json.RawMessage `json:"child,omitempty"`
State string `json:"state,omitempty"`
}
type Input struct {
PaneID string `json:"pane_id"`
Bytes []byte `json:"bytes"`
}
type Focus struct {
PaneID string `json:"pane_id,omitempty"`
Pad string `json:"pad,omitempty"`
}
type SwitchProject struct {
Key string `json:"key"`
}
type OpenProject struct {
Path string `json:"path"`
}
type PaletteCommand struct {
Kind string `json:"kind"`
Data json.RawMessage `json:"data,omitempty"`
}
type TrustResponse struct {
ProcessID string `json:"process_id"`
Preset string `json:"preset"`
Allow bool `json:"allow"`
}
type Resize struct {
Size Size `json:"size"`
}

View File

@@ -0,0 +1,67 @@
package protocol
import (
"sync"
)
const defaultLoopbackBuffer = 64
// NewLoopbackPair returns connected in-process transports. Frames cross the
// same Send/Recv boundary as network transports, but payload bytes are passed
// directly without JSON re-encoding.
func NewLoopbackPair() (client Transport, daemon Transport) {
c2d := make(chan Frame, defaultLoopbackBuffer)
d2c := make(chan Frame, defaultLoopbackBuffer)
return &loopbackTransport{send: c2d, recv: d2c}, &loopbackTransport{send: d2c, recv: c2d}
}
type loopbackTransport struct {
send chan<- Frame
recv <-chan Frame
once sync.Once
done chan struct{}
}
func (t *loopbackTransport) init() {
if t.done == nil {
t.done = make(chan struct{})
}
}
func (t *loopbackTransport) Send(f Frame) error {
t.init()
select {
case <-t.done:
return ErrTransportClosed
case t.send <- cloneFrame(f):
return nil
}
}
func (t *loopbackTransport) Recv() (Frame, error) {
t.init()
select {
case <-t.done:
return Frame{}, ErrTransportClosed
case f, ok := <-t.recv:
if !ok {
return Frame{}, ErrTransportClosed
}
return f, nil
}
}
func (t *loopbackTransport) Close() error {
t.init()
t.once.Do(func() {
close(t.done)
})
return nil
}
func cloneFrame(f Frame) Frame {
if len(f.Payload) > 0 {
f.Payload = append([]byte(nil), f.Payload...)
}
return f
}

View File

@@ -0,0 +1,51 @@
package protocol
import "testing"
func TestLoopbackUsesFramePayload(t *testing.T) {
client, daemon := NewLoopbackPair()
defer client.Close()
defer daemon.Close()
sent, err := NewFrame(FrameInput, Input{PaneID: "p_123456", Bytes: []byte("hello")})
if err != nil {
t.Fatalf("NewFrame: %v", err)
}
if err := client.Send(sent); err != nil {
t.Fatalf("Send: %v", err)
}
got, err := daemon.Recv()
if err != nil {
t.Fatalf("Recv: %v", err)
}
if got.Type != FrameInput {
t.Fatalf("type = %q, want %q", got.Type, FrameInput)
}
payload, err := Decode[Input](got)
if err != nil {
t.Fatalf("Decode: %v", err)
}
if payload.PaneID != "p_123456" || string(payload.Bytes) != "hello" {
t.Fatalf("payload = %#v", payload)
}
}
func TestLoopbackCopiesPayloadOnSend(t *testing.T) {
client, daemon := NewLoopbackPair()
defer client.Close()
defer daemon.Close()
f := Frame{Type: FramePaneChunk, Payload: []byte(`{"pane_id":"p","bytes":"aGVsbG8="}`)}
if err := client.Send(f); err != nil {
t.Fatalf("Send: %v", err)
}
f.Payload[0] = 'x'
got, err := daemon.Recv()
if err != nil {
t.Fatalf("Recv: %v", err)
}
if got.Payload[0] != '{' {
t.Fatalf("payload was retained instead of copied: %q", string(got.Payload))
}
}

View File

@@ -0,0 +1,73 @@
package protocol
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"net"
)
var ErrTransportClosed = errors.New("protocol: transport closed")
// Transport carries framed daemon/client protocol messages.
type Transport interface {
Send(Frame) error
Recv() (Frame, error)
Close() error
}
// ConnTransport is a JSON-lines implementation over a stream connection.
type ConnTransport struct {
conn net.Conn
r *bufio.Reader
w *bufio.Writer
}
func NewConnTransport(conn net.Conn) *ConnTransport {
return &ConnTransport{
conn: conn,
r: bufio.NewReader(conn),
w: bufio.NewWriter(conn),
}
}
func (t *ConnTransport) Send(f Frame) error {
if t == nil || t.conn == nil {
return ErrTransportClosed
}
b, err := json.Marshal(f)
if err != nil {
return fmt.Errorf("protocol: encode frame: %w", err)
}
if _, err := t.w.Write(append(b, '\n')); err != nil {
return err
}
return t.w.Flush()
}
func (t *ConnTransport) Recv() (Frame, error) {
if t == nil || t.conn == nil {
return Frame{}, ErrTransportClosed
}
line, err := t.r.ReadBytes('\n')
if err != nil {
if errors.Is(err, io.EOF) {
return Frame{}, ErrTransportClosed
}
return Frame{}, err
}
var f Frame
if err := json.Unmarshal(line, &f); err != nil {
return Frame{}, fmt.Errorf("protocol: decode frame: %w", err)
}
return f, nil
}
func (t *ConnTransport) Close() error {
if t == nil || t.conn == nil {
return nil
}
return t.conn.Close()
}

View File

@@ -6,6 +6,7 @@ import (
"io" "io"
"os" "os"
"os/exec" "os/exec"
"syscall"
cpty "github.com/creack/pty" cpty "github.com/creack/pty"
) )
@@ -19,11 +20,13 @@ type PTY struct {
// Start spawns argv with stdin/stdout/stderr attached to a new PTY sized // Start spawns argv with stdin/stdout/stderr attached to a new PTY sized
// (cols, rows). The returned PTY exposes the master fd for the parent to // (cols, rows). The returned PTY exposes the master fd for the parent to
// read from and write to. // read from and write to.
func Start(argv []string, env []string, cols, rows uint16) (*PTY, error) { func Start(argv []string, env []string, workDir string, cols, rows uint16) (*PTY, error) {
if len(argv) == 0 { if len(argv) == 0 {
return nil, fmt.Errorf("pty: empty argv") return nil, fmt.Errorf("pty: empty argv")
} }
cmd := exec.Command(argv[0], argv[1:]...) cmd := exec.Command(argv[0], argv[1:]...)
cmd.Dir = workDir
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true}
if env != nil { if env != nil {
cmd.Env = ensureTerm(env) cmd.Env = ensureTerm(env)
} else { } else {
@@ -88,6 +91,10 @@ func (p *PTY) Close() error {
p.master = nil p.master = nil
} }
if p.cmd != nil && p.cmd.Process != nil { if p.cmd != nil && p.cmd.Process != nil {
pid := p.cmd.Process.Pid
if pid > 0 {
_ = syscall.Kill(-pid, syscall.SIGKILL)
}
_ = p.cmd.Process.Kill() _ = p.cmd.Process.Kill()
} }
return firstErr return firstErr

84
internal/pty/pty_test.go Normal file
View File

@@ -0,0 +1,84 @@
package pty
import (
"bytes"
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
func TestStartUsesWorkDir(t *testing.T) {
dir := t.TempDir()
p, err := Start([]string{"sh", "-c", "pwd"}, nil, dir, 80, 24)
if err != nil {
t.Fatalf("Start: %v", err)
}
defer p.Close()
var out bytes.Buffer
buf := make([]byte, 256)
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
n, err := p.Read(buf)
if n > 0 {
out.Write(buf[:n])
if strings.Contains(out.String(), dir) {
break
}
}
if err != nil {
break
}
}
_ = p.Wait()
if got := strings.TrimSpace(out.String()); got != dir {
t.Fatalf("pwd output = %q, want %q", got, dir)
}
}
func TestCloseKillsProcessGroup(t *testing.T) {
dir := t.TempDir()
pidFile := filepath.Join(dir, "sleep.pid")
env := append(os.Environ(), "PIDFILE="+pidFile)
p, err := Start([]string{"sh", "-c", "sleep 30 & echo $! > \"$PIDFILE\"; wait"}, env, "", 80, 24)
if err != nil {
t.Fatalf("Start: %v", err)
}
deadline := time.Now().Add(5 * time.Second)
var childPID int
for time.Now().Before(deadline) {
b, err := os.ReadFile(pidFile)
if err == nil {
childPID, _ = strconv.Atoi(strings.TrimSpace(string(b)))
if childPID > 0 {
break
}
}
time.Sleep(20 * time.Millisecond)
}
if childPID <= 0 {
_ = p.Close()
t.Fatalf("background child pid was not written")
}
if err := p.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
_ = p.Wait()
deadline = time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
err := syscall.Kill(childPID, 0)
if errors.Is(err, syscall.ESRCH) {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("background child pid %d still exists after PTY.Close", childPID)
}