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:
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user