Skip to content

Commit da77a69

Browse files
committed
fix(desktop): ask the OS about terminals without shell integration before an update restart
Shells that announce nothing (fish, sh) now count as busy when their tty's foreground process group is not the shell's own. Live windows are recorded for relaunch without the cap, which only bounds persisted routes on read.
1 parent eb7c282 commit da77a69

4 files changed

Lines changed: 81 additions & 9 deletions

File tree

‎apps/desktop/src/main/index.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -598,7 +598,6 @@ function main(): void {
598598
const otherRoutes = getWindows()
599599
.filter((win) => win !== active)
600600
.flatMap((win) => routeFromAppUrl(win.webContents.getURL()) ?? [])
601-
.slice(0, MAX_RELAUNCH_WINDOWS)
602601
config.set('updateRelaunchRoutes', otherRoutes.length > 0 ? otherRoutes : undefined)
603602
config.flush()
604603
}
@@ -1171,9 +1170,10 @@ function main(): void {
11711170
const windows = await countWindowsWithUnsavedChanges(
11721171
getWindows().map((win) => win.webContents)
11731172
)
1173+
const terminalCommands = await terminal.runningCommands()
11741174
const computerUseActivity = computerUse.getActivity()
11751175
return describeInFlightWork({
1176-
terminalCommands: terminal.runningCommands(),
1176+
terminalCommands,
11771177
computerUseApp: computerUseActivity ? (computerUseActivity.appName ?? '') : null,
11781178
browserToolRunning: isBrowserToolRunning(),
11791179
agentWorkRunning,

‎apps/desktop/src/main/terminal/index.ts‎

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
type TerminalToolArgs,
3131
type TerminalToolResponse,
3232
} from '@sim/terminal-protocol'
33+
import { getErrorMessage } from '@sim/utils/errors'
3334
import { sleep } from '@sim/utils/helpers'
3435
import type { BrowserWindow, WebContents } from 'electron'
3536
import {
@@ -218,6 +219,7 @@ const STOP_ESCALATION_MS = 2_000
218219
export class TerminalService {
219220
/** Insertion-ordered, which is also the tab order the user sees. */
220221
private readonly sessions = new Map<string, TerminalSession>()
222+
private foregroundReadFailureLogged = false
221223
private activeId: string | null = null
222224
private readonly pendingCloseConfirmations = new Set<string>()
223225
private agentActiveId: string | null = null
@@ -303,11 +305,32 @@ export class TerminalService {
303305
this.cwdTimer = null
304306
}
305307

306-
/** The command line holding each busy terminal's foreground, `''` when unannounced. */
307-
runningCommands(): string[] {
308-
return [...this.sessions.values()].flatMap((session) =>
309-
session.isBusy ? [session.foreground ?? ''] : []
308+
/**
309+
* The command line holding each busy terminal's foreground, `''` when unannounced. A shell
310+
* without integration (fish, sh) announces nothing, so the OS's foreground process group
311+
* answers for it; a failed read counts as idle so a restart is never held by a stale guess.
312+
*/
313+
async runningCommands(): Promise<string[]> {
314+
const groups = this.options.processGroups ?? OS_PROCESS_GROUPS
315+
const commands = await Promise.all(
316+
[...this.sessions.values()].map(async (session) => {
317+
if (session.isBusy) return [session.foreground ?? '']
318+
if (session.hasShellIntegration) return []
319+
try {
320+
const pgid = await groups.foreground(session.pid)
321+
return pgid !== null && pgid !== session.pid ? [''] : []
322+
} catch (error) {
323+
if (!this.foregroundReadFailureLogged) {
324+
this.foregroundReadFailureLogged = true
325+
logger.warn('Could not read a terminal foreground process group', {
326+
error: getErrorMessage(error),
327+
})
328+
}
329+
return []
330+
}
331+
})
310332
)
333+
return commands.flat()
311334
}
312335

313336
getTabs(): TerminalTabsState {

‎apps/desktop/src/main/terminal/registry.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -376,8 +376,11 @@ export class TerminalRegistry {
376376
}
377377

378378
/** The command line holding each busy terminal's foreground, across every chat. */
379-
runningCommands(): string[] {
380-
return [...this.entries.values()].flatMap((entry) => entry.service.runningCommands())
379+
async runningCommands(): Promise<string[]> {
380+
const commands = await Promise.all(
381+
[...this.entries.values()].map((entry) => entry.service.runningCommands())
382+
)
383+
return commands.flat()
381384
}
382385

383386
/**

‎apps/desktop/src/main/terminal/service.test.ts‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ const { stubSessions } = vi.hoisted(() => ({
104104
clearScrollback(): void
105105
/** Whether Ctrl-C ends the running command, as it does for most programs. */
106106
setInterruptible(interruptible: boolean): void
107+
/** Whether the shell announces its commands, as zsh and bash do and fish does not. */
108+
setShellIntegration(on: boolean): void
109+
readonly pid: number
107110
/** Ends the running command the way its process exiting would. */
108111
finishRun(exitCode: number): void
109112
/** Returns the run as still going, its command left running, as a wait that ran out does. */
@@ -142,6 +145,7 @@ vi.mock('@/main/terminal/session', async () => {
142145
disposed: false,
143146
busy: false,
144147
interruptible: true,
148+
shellIntegration: true,
145149
toolCallId: null as string | null,
146150
resolveRun: null as ((result: Record<string, unknown>) => void) | null,
147151
}
@@ -159,6 +163,9 @@ vi.mock('@/main/terminal/session', async () => {
159163
setInterruptible: (interruptible: boolean) => {
160164
state.interruptible = interruptible
161165
},
166+
setShellIntegration: (on: boolean) => {
167+
state.shellIntegration = on
168+
},
162169
finishRun,
163170
handBack: () => {
164171
const resolve = state.resolveRun
@@ -204,7 +211,9 @@ vi.mock('@/main/terminal/session', async () => {
204211
get isBusy() {
205212
return state.busy
206213
},
207-
hasShellIntegration: true,
214+
get hasShellIntegration() {
215+
return state.shellIntegration
216+
},
208217
/** Real sessions poll the pty here; a stub's cwd only ever changes on open. */
209218
refreshCwd: async () => {},
210219
dispose: () => {
@@ -477,6 +486,43 @@ describe('a shell that ends by itself', () => {
477486
})
478487
})
479488

489+
describe('running work before a restart', () => {
490+
function serviceWithForeground(read: (shellPid: number) => Promise<number | null>) {
491+
const terminal = new TerminalService({
492+
loadCwd: () => '/tmp',
493+
processGroups: { foreground: vi.fn(read), signal: vi.fn() },
494+
})
495+
const { activeTerminalId } = terminal.start({ cols: 80, rows: 24 })
496+
const session = stubSessions.get(activeTerminalId as string)
497+
if (!session) throw new Error('No stub session')
498+
return { terminal, session }
499+
}
500+
501+
it('asks the OS about a shell that announces nothing, and trusts one that does', async () => {
502+
const { terminal, session } = serviceWithForeground(async () => 4242)
503+
session.setShellIntegration(false)
504+
await expect(terminal.runningCommands()).resolves.toEqual([''])
505+
506+
session.setShellIntegration(true)
507+
await expect(terminal.runningCommands()).resolves.toEqual([])
508+
})
509+
510+
it('counts a shell at its own prompt, or an unreadable foreground, as idle', async () => {
511+
let read: () => Promise<number | null> = async () => null
512+
const { terminal, session } = serviceWithForeground(() => read())
513+
session.setShellIntegration(false)
514+
await expect(terminal.runningCommands()).resolves.toEqual([])
515+
516+
read = async () => session.pid
517+
await expect(terminal.runningCommands()).resolves.toEqual([])
518+
519+
read = async () => {
520+
throw new Error('ps is unavailable')
521+
}
522+
await expect(terminal.runningCommands()).resolves.toEqual([])
523+
})
524+
})
525+
480526
describe('stopping a tool call', () => {
481527
const COMMAND_GROUP = 4242
482528

0 commit comments

Comments
 (0)