feat: open local runtime clients from the composer

Add composer shortcuts for local Continue and OpenCode terminals and the official DeepSeek Harness Web client, using the current project and model bridge. Manage browser service reuse, stop actions and terminal cleanup. Upgrade DeepSeek Harness to 0.1.7-rc.2 and Cordis to 4.0.4.

Include the coordinated IPC handler extraction and related tests and documentation. Remote native-client coordinator wiring, managed Node consolidation, Ask MCP mappings and full desktop/package acceptance remain unfinished; this commit records the implemented local workflow and supporting SSH groundwork.

Validation: typecheck and lint passed; 267 focused tests passed and 5 skipped. No new real-provider calls were made for this commit. Prior real-model evidence and remaining acceptance work are documented in the feature progress files.

Release note: 本地项目选择 Continue 或 OpenCode 后,可从输入框下方打开原生终端;选择 DS 后,可打开使用当前项目和模型的官方 Web 客户端。客户端使用独立会话,DS 网页关闭后服务继续运行,可在 GoodBuddy 中停止;远程入口尚未完成。
This commit is contained in:
mesalogo
2026-09-27 08:38:00 +08:00
parent 1094433a7d
commit f81f342cde
71 changed files with 11943 additions and 2478 deletions
@@ -93,6 +93,24 @@ function sessionDependencies(
}
describe('local terminal launch resolution', () => {
it('spawns a Main-owned native executable directly without typing a shell command', async () => {
const pty = new FakePty()
const dependencies = sessionDependencies(pty)
const spawnSpec = { executable: '/managed/node', args: ['/managed/cn.js', '--config', '/config with spaces.json'], cwd: '/project', env: { PATH: '/managed' }, label: 'Continue' }
const session = await LocalTerminalSession.create({ ...baseOptions, spawnSpec, dependencies })
expect(dependencies.spawn).toHaveBeenCalledWith(spawnSpec.executable, spawnSpec.args, expect.objectContaining({ cwd: '/project', env: spawnSpec.env }))
expect(pty.write).not.toHaveBeenCalled()
expect(session.snapshot()).toMatchObject({ state: 'running', shell: 'Continue', workingDirectory: '/project' })
await session.close()
})
it('fails a native launch with an invalid project directory instead of falling back home', async () => {
const dependencies = sessionDependencies(new FakePty(), { directoryExists: async (path: string) => path === '/home/tester' })
const session = await LocalTerminalSession.create({ ...baseOptions, dependencies,
spawnSpec: { executable: '/managed/opencode', args: [], cwd: '/missing', env: {}, label: 'OpenCode' } })
expect(session.snapshot().state).toBe('failed')
expect(dependencies.spawn).not.toHaveBeenCalled()
})
it('prefers pwsh, then PowerShell, then COMSPEC on Windows', async () => {
const available = new Set(['C:\\bin\\powershell.EXE'])
const shell = await resolveLocalTerminalShell({
+21 -2
View File
@@ -29,6 +29,15 @@ export type LocalTerminalLaunch = {
env: NodeJS.ProcessEnv
}
/** Main-owned launch material. Never accepted by the terminal IPC contract. */
export type NativeTerminalSpawnSpec = {
executable: string
args: string[]
cwd: string
env: NodeJS.ProcessEnv
label: string
}
export type LocalPtySpawn = (
file: string,
args: string[],
@@ -52,6 +61,7 @@ export type LocalTerminalSessionOptions = {
title: string
size: TerminalSize
projectDirectory?: string
spawnSpec?: NativeTerminalSpawnSpec
dependencies?: LocalTerminalDependencies
}
@@ -370,14 +380,23 @@ export class LocalTerminalSession {
private async start(): Promise<void> {
try {
this.launch = await resolveLocalTerminalLaunch(
const spec = this.options.spawnSpec
if (spec && !(await (this.dependencies.directoryExists ?? defaultDirectoryExists)(spec.cwd))) {
throw new Error('Native client project directory is unavailable')
}
this.launch = spec ? {
shell: spec.executable,
shellLabel: spec.label,
cwd: spec.cwd,
env: spec.env
} : await resolveLocalTerminalLaunch(
this.options.target.type === 'project'
? this.options.projectDirectory
: undefined,
this.dependencies
)
const spawn = this.dependencies.spawn ?? nodePty.spawn
this.pty = spawn(this.launch.shell, [], {
this.pty = spawn(this.launch.shell, spec?.args ?? [], {
name: 'xterm-256color',
cols: this.size.cols,
rows: this.size.rows,
@@ -64,6 +64,7 @@ function createHarness(idleTimeoutMs = 60_000) {
const connection = {
identity,
isUsable: vi.fn(() => usable),
forwardLoopback: vi.fn(),
onDisconnect: vi.fn((listener: (error?: Error) => void) => {
disconnectListeners.add(listener);
return () => disconnectListeners.delete(listener);
+3 -1
View File
@@ -25,6 +25,7 @@ export type SshTerminalSessionOptions = {
targetLabel: string;
title: string;
shell?: string;
command?: string;
workingDirectory?: string;
size: TerminalSize;
poolTarget: SshConnectionPoolTarget;
@@ -95,7 +96,7 @@ export async function createSshTerminalSession(
options: SshTerminalSessionOptions,
): Promise<SshTerminalSession> {
const initialSize = terminalSizeSchema.parse(options.size);
const workingDirectoryCommand = options.workingDirectory
const workingDirectoryCommand = options.workingDirectory && !options.command
? `cd -- ${quotePosixShellArgument(options.workingDirectory)} || exit\r`
: undefined;
const lease = await pool.acquireTerminal(options.poolTarget, options.signal);
@@ -106,6 +107,7 @@ export async function createSshTerminalSession(
cols: initialSize.cols,
rows: initialSize.rows,
term: SSH_TERMINAL_TERM,
...(options.command ? { command: options.command } : {}),
},
options.signal,
);
+111
View File
@@ -0,0 +1,111 @@
import type { BrowserWindow, ipcMain } from 'electron'
import { ipcChannels } from '../../shared/ipc-channels'
import {
terminalAckRequestSchema,
terminalCloseRequestSchema,
terminalCreateRequestSchema,
terminalResizeRequestSchema,
terminalSnapshotRequestSchema,
terminalWriteRequestSchema
} from '../../shared/terminal-contracts'
import { assertTrustedSender } from '../trusted-ipc-sender'
import type { TerminalSessionManager } from './terminal-session-manager'
export function registerTerminalIpcHandlers(
registerHandler: typeof ipcMain.handle,
window: BrowserWindow,
terminalSessionManager?: TerminalSessionManager
): void {
const requireTerminalSessionManager = (): TerminalSessionManager => {
if (!terminalSessionManager) {
throw new Error('终端服务不可用')
}
return terminalSessionManager
}
registerHandler(
ipcChannels.terminalCreate,
async (event, input: unknown) => {
assertTrustedSender(event, window)
const manager = requireTerminalSessionManager()
const snapshot = await manager.create(
event.sender.id,
terminalCreateRequestSchema.parse(input)
)
setImmediate(() => {
if (
!window.isDestroyed() &&
event.sender === window.webContents &&
!event.sender.isDestroyed()
) {
manager.enableEventDelivery(
event.sender.id,
snapshot.sessionId
)
}
})
return snapshot
}
)
registerHandler(ipcChannels.terminalWrite, (event, input: unknown) => {
assertTrustedSender(event, window)
const manager = requireTerminalSessionManager()
const request = terminalWriteRequestSchema.parse(input)
manager.write(
event.sender.id,
request.sessionId,
request.data
)
})
registerHandler(ipcChannels.terminalResize, (event, input: unknown) => {
assertTrustedSender(event, window)
const manager = requireTerminalSessionManager()
const request = terminalResizeRequestSchema.parse(input)
manager.resize(event.sender.id, request.sessionId, {
cols: request.cols,
rows: request.rows
})
})
registerHandler(
ipcChannels.terminalSnapshot,
(event, input: unknown) => {
assertTrustedSender(event, window)
const manager = requireTerminalSessionManager()
const request = terminalSnapshotRequestSchema.parse(input)
return manager.snapshot(
event.sender.id,
request.sessionId
)
}
)
registerHandler(
ipcChannels.terminalAck,
(event, input: unknown) => {
assertTrustedSender(event, window)
const manager = requireTerminalSessionManager()
const request = terminalAckRequestSchema.parse(input)
manager.acknowledge(
event.sender.id,
request.sessionId,
request.sequence
)
}
)
registerHandler(
ipcChannels.terminalClose,
async (event, input: unknown) => {
assertTrustedSender(event, window)
const manager = requireTerminalSessionManager()
const request = terminalCloseRequestSchema.parse(input)
return manager.close(
event.sender.id,
request.sessionId
)
}
)
}
@@ -160,6 +160,28 @@ function harness(
}
describe('TerminalSessionManager routing and delivery', () => {
it('releases native resources on exit before delivery, exactly once through close', async () => {
const test = harness()
const dispose = vi.fn(async () => undefined)
const launch = { spawnSpec: { executable: '/runtime', args: [], cwd: '/authoritative/root', env: {}, label: 'OpenCode' }, title: 'OpenCode', dispose }
const result = await test.manager.create(1, { target: { type: 'project', projectId }, cols: 80, rows: 24 }, launch)
expect(test.createLocalSession.mock.calls[0]?.[0]).toMatchObject({ spawnSpec: launch.spawnSpec, title: 'OpenCode' })
test.localSessions[0]!.emit({ sessionId: result.sessionId, sequence: 2, type: 'state', state: 'exited' })
expect(dispose).toHaveBeenCalledTimes(1)
expect(test.delivered).toHaveLength(0)
await test.manager.close(1, result.sessionId)
expect(dispose).toHaveBeenCalledTimes(1)
})
it('releases native resources when execution-space resolution disagrees', async () => {
const test = harness()
const dispose = vi.fn(async () => undefined)
await expect(test.manager.create(1, { target: { type: 'project', projectId }, cols: 80, rows: 24 }, {
spawnSpec: { executable: '/runtime', args: [], cwd: '/different', env: {}, label: 'OpenCode' }, title: 'OpenCode', dispose
})).rejects.toThrow('execution space')
expect(dispose).toHaveBeenCalledOnce()
expect(test.createLocalSession).not.toHaveBeenCalled()
})
it('routes Home local and authoritative local project roots', async () => {
const test = harness()
const local = await test.manager.create(1, {
+42 -5
View File
@@ -19,6 +19,7 @@ import type {
} from '../ssh/ssh-connection-pool'
import {
LocalTerminalSession,
type NativeTerminalSpawnSpec,
type LocalTerminalSessionOptions
} from './local-terminal-session'
import {
@@ -73,6 +74,14 @@ type SessionRecord = {
ready: Promise<ManagedTerminalSession>
closePromise?: Promise<TerminalSnapshot>
removeEventListener?: () => void
releaseResources?: () => Promise<void>
}
export type NativeTerminalLaunch = {
spawnSpec: NativeTerminalSpawnSpec
title: string
dispose(): Promise<void>
remote?: { hostId: string; hostRevision: number; hostKeyGeneration: number; command: string }
}
type ResolvedLaunch =
@@ -103,7 +112,8 @@ export class TerminalSessionManager {
async create(
ownerWebContentsId: number,
request: TerminalCreateRequest
request: TerminalCreateRequest,
nativeLaunch?: NativeTerminalLaunch
): Promise<TerminalSnapshot> {
this.assertOwnerId(ownerWebContentsId)
if (this.disposed) {
@@ -130,16 +140,26 @@ export class TerminalSessionManager {
closing: false,
ready: Promise.resolve(undefined as never)
}
if (nativeLaunch) {
let release: Promise<void> | undefined
record.releaseResources = () => release ??= nativeLaunch.dispose()
}
this.records.set(sessionId, record)
record.ready = this.createSession(record, request)
record.ready = this.createSession(record, request, nativeLaunch)
try {
const session = await record.ready
if (nativeLaunch && session.snapshot().state === 'failed') {
const message = session.snapshot().error?.message ?? 'Native terminal failed to start'
await this.closeRecord(record)
throw new Error(message)
}
if (record.closing) {
return await this.closeRecord(record)
}
return session.snapshot()
} catch (error) {
await record.releaseResources?.()
if (this.records.get(sessionId) === record) {
this.records.delete(sessionId)
}
@@ -263,14 +283,24 @@ export class TerminalSessionManager {
private async createSession(
record: SessionRecord,
request: TerminalCreateRequest
request: TerminalCreateRequest,
nativeLaunch?: NativeTerminalLaunch
): Promise<ManagedTerminalSession> {
const launch = await this.resolveLaunch(request)
if (nativeLaunch) {
const remote = nativeLaunch.remote
const matches = remote
? launch.kind === 'ssh' && launch.workingDirectory === nativeLaunch.spawnSpec.cwd &&
launch.poolTarget.host.id === remote.hostId && launch.poolTarget.hostRevision === remote.hostRevision &&
launch.poolTarget.hostKeyGeneration === remote.hostKeyGeneration
: launch.kind === 'local' && launch.projectDirectory === nativeLaunch.spawnSpec.cwd
if (!matches) throw new Error('Native terminal launch does not match the project execution space')
}
const common = {
sessionId: record.sessionId,
target: request.target,
targetLabel: launch.targetLabel,
title: `终端 · ${launch.targetLabel}`,
title: nativeLaunch?.title ?? `终端 · ${launch.targetLabel}`,
size: { cols: request.cols, rows: request.rows }
}
let session: ManagedTerminalSession
@@ -284,7 +314,8 @@ export class TerminalSessionManager {
TerminalCreateRequest['target'],
{ type: 'local' | 'project' }
>,
projectDirectory: launch.projectDirectory
projectDirectory: launch.projectDirectory,
...(nativeLaunch ? { spawnSpec: nativeLaunch.spawnSpec } : {})
})
record.session = session
record.removeEventListener = session.onEvent?.((event) =>
@@ -299,6 +330,7 @@ export class TerminalSessionManager {
...common,
workingDirectory: launch.workingDirectory,
poolTarget: launch.poolTarget,
...(nativeLaunch?.remote ? { command: nativeLaunch.remote.command, shell: nativeLaunch.spawnSpec.label } : {}),
onEvent: (event) => this.handleEvent(record, event)
})
record.session = session
@@ -376,6 +408,10 @@ export class TerminalSessionManager {
if (this.records.get(record.sessionId) !== record) {
return
}
if (event.type === 'state' && ['exited', 'failed', 'interrupted'].includes(event.state)) {
// Cleanup must also happen while event delivery is waiting for the IPC reply.
void record.releaseResources?.().catch(() => undefined)
}
if (!record.deliveryEnabled) {
record.delayedEvents.push(event)
return
@@ -413,6 +449,7 @@ export class TerminalSessionManager {
try {
const session = await record.ready
const closed = await session.close()
await record.releaseResources?.()
const snapshot =
closed && typeof closed === 'object' && 'sessionId' in closed
? closed