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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions resources/mcp-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ const TOOLS = [
{
name: 'create_shell',
description:
"Spawn a new shell tab in this worktree. If `command` is set, runs it via `zsh -ilc <command>`; otherwise opens an interactive login shell. Returns the new shell's id — keep it so you can read_shell_output / kill_shell later. Use this instead of telling the user to run `npm run dev` by hand.",
"Spawn a new shell tab in this worktree. If `command` is set, runs it via `zsh -ilc <command>`; otherwise opens an interactive login shell. Returns the new shell's id — keep it so you can read_shell_output / kill_shell later. Use this instead of telling the user to run `npm run dev` by hand. By default the tab auto-closes 30s after the command finishes successfully (override with close_delay); a failed command leaves the tab open.",
inputSchema: {
type: 'object',
properties: {
Expand All @@ -368,6 +368,16 @@ const TOOLS = [
type: 'string',
description:
'Optional short label shown on the tab. Defaults to a truncated form of the command.'
},
background: {
type: 'boolean',
description:
'When true, the tab is created in the background. Use for commands you want to run without interrupting the user. Defaults to false.'
},
close_delay: {
type: 'number',
description:
'Seconds (integer >= 0) to wait after the command finishes successfully before closing the tab. 0 closes immediately on success. Defaults to 30.'
}
}
}
Expand Down Expand Up @@ -635,13 +645,17 @@ async function handleToolCall(name, args) {
return JSON.stringify((r && r.shells) || [], null, 2)
}
if (name === 'create_shell') {
const r = await callControl('POST', '/shells', {
const body = {
command: (args && args.command) || '',
cwd: (args && args.cwd) || '',
label: (args && args.label) || ''
})
label: (args && args.label) || '',
background: !!(args && args.background)
}
if (args && args.close_delay != null) body.closeDelay = args.close_delay
const r = await callControl('POST', '/shells', body)
const commandPart = args && args.command ? ' (' + args.command + ')' : ''
return 'Created shell ' + r.id + ' "' + r.label + '"' + commandPart
const bgPart = body.background ? ' [background]' : ''
return 'Created shell ' + r.id + ' "' + r.label + '"' + commandPart + bgPart
}
if (name === 'read_shell_output') {
if (!args || !args.shell_id) throw new Error('shell_id is required')
Expand Down
20 changes: 18 additions & 2 deletions src/main/control-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,13 @@ export interface ShellQueries {
) => { output: string; matchCount?: number; error?: string }
createShell: (
worktreePath: string,
opts: { command?: string; cwd?: string; label?: string }
opts: {
command?: string
cwd?: string
label?: string
background?: boolean
closeDelay?: number
}
) => { id: string; label: string }
killShell: (shellId: string) => void
}
Expand Down Expand Up @@ -507,10 +513,20 @@ async function handleRequest(
const command = typeof body.command === 'string' ? body.command.trim() : ''
const cwd = typeof body.cwd === 'string' ? body.cwd.trim() : ''
const label = typeof body.label === 'string' ? body.label.trim() : ''
const background = body.background === true
// closeDelay defaults to 30s when omitted; a provided value must be a
// non-negative finite number (0 = close immediately on success).
const rawDelay = body.closeDelay
const closeDelay =
typeof rawDelay === 'number' && Number.isFinite(rawDelay) && rawDelay >= 0
? Math.floor(rawDelay)
: 30
const created = deps.shell.createShell(callerWorktree, {
command: command || undefined,
cwd: cwd || undefined,
label: label || undefined
label: label || undefined,
background,
closeDelay
})
return sendJson(res, 200, created)
}
Expand Down
38 changes: 30 additions & 8 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { WorktreeDeletionFSM } from './worktree-deletion-fsm'
import { PanesFSM, stripTransientTabFields } from './panes-fsm'
import { ActivityDeriver } from './activity-deriver'
import { AutoSleepMonitor } from './auto-sleep-monitor'
import { ShellAutoCloseMonitor } from './shell-autoclose-monitor'
import { WorktreeWatcher } from './worktree-watcher'
import { SnoozeTimer } from './snooze-timer'
import { getWeeklyStats } from './weekly-stats'
Expand Down Expand Up @@ -868,6 +869,11 @@ const activityDeriver = new ActivityDeriver(store)
// drives panesFSM.sleepJsonClaudeTab.
const autoSleepMonitor = new AutoSleepMonitor(store, panesFSM)

// Auto-closes shell tabs a configurable delay after a successful command
// exit. Self-wires to ptyManager's exit listener in its constructor.
const shellAutoCloseMonitor = new ShellAutoCloseMonitor(store, panesFSM, ptyManager)
void shellAutoCloseMonitor

/** Install agent status hooks at the user-scope settings file for both
* supported agents. Called once when consent flips to 'accepted'. The
* hook command is env-gated on $HARNESS_TERMINAL_ID, so it no-ops for
Expand Down Expand Up @@ -2384,6 +2390,15 @@ function registerIpcHandlers(): void {
else if (type === 'shell') panesFSM.wakeShellTab(wtPath, tabId)
return true
})
// "Keep open" / re-arm for a shell tab's auto-close. delay === null
// disarms (clears closeDelay); a number re-arms it.
transport.onRequest(
'panes:setShellCloseDelay',
(_ctx, wtPath: string, tabId: string, delay: number | null) => {
panesFSM.setShellCloseDelay(wtPath, tabId, delay)
return true
}
)
// Renderer-driven lastActive bump. The composer fires this while the
// user is typing so the auto-sleep monitor can't re-sleep a tab mid-
// composition — ActivityDeriver only bumps lastActive on status
Expand Down Expand Up @@ -3758,17 +3773,24 @@ async function runBoot(): Promise<void> {
const finalLines = kept.length > lines ? kept.slice(-lines) : kept
return { output: finalLines.join('\n'), matchCount }
},
createShell: (wtPath, { command, cwd, label }) => {
createShell: (wtPath, { command, cwd, label, background, closeDelay }) => {
const id = `shell-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const fallback = command ? command.slice(0, 32) : 'Shell'
const finalLabel = (label && label.trim()) || fallback
panesFSM.addTab(wtPath, {
id,
type: 'shell',
label: finalLabel,
command,
cwd
})
panesFSM.addTab(
wtPath,
{
id,
type: 'shell',
label: finalLabel,
command,
cwd,
background: background || undefined,
closeDelay
},
undefined,
{ activate: !background }
)
return { id, label: finalLabel }
},
killShell: (shellId) => {
Expand Down
57 changes: 57 additions & 0 deletions src/main/panes-fsm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,63 @@ describe('PanesFSM.restoreFromConfig', () => {
})
})

describe('PanesFSM.addTab background activation', () => {
it('activate:false appends without changing the leaf activeTabId', () => {
const { fsm, store } = buildFSM()
const wtPath = '/wt/bg'
seedLeaf(store, wtPath, {
type: 'leaf',
id: 'p1',
tabs: [{ id: 'agent-1', type: 'agent', label: 'Claude' }],
activeTabId: 'agent-1'
})
fsm.addTab(
wtPath,
{ id: 'sh-1', type: 'shell', label: 'build', background: true },
undefined,
{ activate: false }
)
const leaf = store.getSnapshot().state.terminals.panes[wtPath] as PaneLeaf
expect(leaf.tabs.map((t) => t.id)).toEqual(['agent-1', 'sh-1'])
expect(leaf.activeTabId).toBe('agent-1')
})

it('default (activate) makes the new tab active', () => {
const { fsm, store } = buildFSM()
const wtPath = '/wt/fg'
seedLeaf(store, wtPath, {
type: 'leaf',
id: 'p1',
tabs: [{ id: 'agent-1', type: 'agent', label: 'Claude' }],
activeTabId: 'agent-1'
})
fsm.addTab(wtPath, { id: 'sh-1', type: 'shell', label: 'build' })
const leaf = store.getSnapshot().state.terminals.panes[wtPath] as PaneLeaf
expect(leaf.activeTabId).toBe('sh-1')
})
})

describe('PanesFSM.selectTab', () => {
it('clears the background flag when a background tab is selected', () => {
const { fsm, store } = buildFSM()
const wtPath = '/wt/sel'
seedLeaf(store, wtPath, {
type: 'leaf',
id: 'p1',
tabs: [
{ id: 'agent-1', type: 'agent', label: 'Claude' },
{ id: 'sh-1', type: 'shell', label: 'build', background: true }
],
activeTabId: 'agent-1'
})
fsm.selectTab(wtPath, 'p1', 'sh-1')
const leaf = store.getSnapshot().state.terminals.panes[wtPath] as PaneLeaf
expect(leaf.activeTabId).toBe('sh-1')
const shellTab = leaf.tabs.find((t) => t.id === 'sh-1')
expect(shellTab?.background).toBeUndefined()
})
})

describe('PanesFSM.openFileTab', () => {
it('appends a file tab with the file-<path> id and basename label', () => {
const { fsm, store } = buildFSM()
Expand Down
45 changes: 40 additions & 5 deletions src/main/panes-fsm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,16 @@ export class PanesFSM {
return pane
}

addTab(wtPath: string, tab: TerminalTab, paneId?: string): void {
addTab(
wtPath: string,
tab: TerminalTab,
paneId?: string,
opts?: { activate?: boolean }
): void {
// Background tabs (create_shell background:true) are appended without
// stealing focus — the leaf keeps its current activeTabId. A brand-new
// leaf is the exception below: a lone tab is unavoidably active.
const activate = opts?.activate !== false
// Brand-new json-claude tabs default to 'awake' — the user just
// clicked to create one, so the renderer's auto-spawn path should
// proceed. Slept-by-default only applies to tabs hydrated from
Expand All @@ -313,7 +322,7 @@ export class PanesFSM {
return {
...leaf,
tabs: [...leaf.tabs, normalizedTab],
activeTabId: normalizedTab.id
activeTabId: activate ? normalizedTab.id : leaf.activeTabId
}
})
this.commit(wtPath, updated)
Expand Down Expand Up @@ -479,9 +488,21 @@ export class PanesFSM {
selectTab(wtPath: string, paneId: string, tabId: string): void {
const tree = this.getTree(wtPath)
if (!tree || !findLeaf(tree, paneId)) return
const updated = mapLeaves(tree, (leaf) =>
leaf.id === paneId ? { ...leaf, activeTabId: tabId } : leaf
)
const updated = mapLeaves(tree, (leaf) => {
if (leaf.id !== paneId) return leaf
// Selecting a background shell promotes it to a normal tab: drop the
// `background` flag so its title stops rendering italic.
const i = leaf.tabs.findIndex((t) => t.id === tabId)
if (i !== -1 && leaf.tabs[i].background) {
const promoted: TerminalTab = { ...leaf.tabs[i], background: undefined }
return {
...leaf,
activeTabId: tabId,
tabs: [...leaf.tabs.slice(0, i), promoted, ...leaf.tabs.slice(i + 1)]
}
}
return { ...leaf, activeTabId: tabId }
})
this.commit(wtPath, updated)
}

Expand All @@ -497,6 +518,20 @@ export class PanesFSM {
this.opts.persist(this.buildPersistPayload())
}

/** Arm (number) or disarm (null) a shell tab's auto-close delay. Drives
* the "Keep open" button — the ShellAutoCloseMonitor re-reads this at
* fire time, so clearing it cancels a pending close. */
setShellCloseDelay(wtPath: string, tabId: string, closeDelay: number | null): void {
const tree = this.getTree(wtPath)
if (!tree) return
if (!findLeafByTabId(tree, tabId)) return
this.store.dispatch({
type: 'terminals/tabCloseDelayChanged',
payload: { worktreePath: wtPath, tabId, closeDelay }
})
this.opts.persist(this.buildPersistPayload())
}

/** Activate the existing review tab for this worktree, or create one if
* none exists. Only one review tab can live per worktree at a time —
* every entry point in the renderer funnels through here. */
Expand Down
16 changes: 16 additions & 0 deletions src/main/pty-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,15 @@ export class PtyManager {
private historyDirty = new Set<string>()
private historyFlushTimer: NodeJS.Timeout | null = null
private perfMonitor: PerfMonitor | null = null
private exitListeners = new Set<(id: string, exitCode: number) => void>()

/** Subscribe to PTY exits with their exit code. Used by the shell
* auto-close monitor, which needs the code (the store's terminals/removed
* event carries only the id). Returns an unsubscribe fn. */
addExitListener(fn: (id: string, exitCode: number) => void): () => void {
this.exitListeners.add(fn)
return () => this.exitListeners.delete(fn)
}

/** Wire the authoritative store after it's constructed. PTY status,
* shell activity, and cleanup events dispatch through it. */
Expand Down Expand Up @@ -225,6 +234,13 @@ export class PtyManager {
this.sendSignal?.('terminal:exit', id, exitCode)
this.ptys.delete(id)
cleanupTerminalLog(id)
for (const fn of this.exitListeners) {
try {
fn(id, exitCode)
} catch {
// a listener throwing must not abort the others' cleanup
}
}
})

this.ptys.set(id, instance)
Expand Down
Loading
Loading