Skip to content
Merged
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
9 changes: 9 additions & 0 deletions packages/local-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Config, resolveConfig, type Config as LocalConfig } from './config.ts'
import { registerHostApi } from './host-api/handler.ts'
import { RemoteMirrorService } from './mirror/service.ts'
import { installRemoteWorkspaceFiles } from './remote/files.ts'
import { installRemoteTerminalController } from './remote/terminal-controller.ts'
import { RemoteTerminals } from './remote/terminal.ts'
import { registerTerminalApi } from './host-api/terminal.ts'
import { RemoteHostsService } from './service.ts'
Expand Down Expand Up @@ -65,6 +66,14 @@ export async function apply(ctx: Context, config: LocalConfig = {}): Promise<voi
ctx.inject(['workspaceFiles'], child => {
installRemoteWorkspaceFiles(child, mirrors, service)
})
ctx.inject(['terminalController'], child => {
const controller = child.get('terminalController')
if (controller === undefined) return
child.effect(
() => installRemoteTerminalController(child, controller, terminals),
'dsh-remote-local: gateway terminal routing',
)
})

ctx.effect(
() => {
Expand Down
102 changes: 102 additions & 0 deletions packages/local-plugin/src/remote/terminal-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* Route gateway terminal calls by Session.
*
* The fork replaces the stock terminal-controller row, but its host half is the
* stock implementation. Gateway clients call the stock Remote methods directly,
* so a mirrored Session would otherwise get a local shell. Patch the service for
* mirrored Sessions and keep the stock implementation for local Sessions.
*
* @module dsh-remote-local/remote/terminal-controller
*/

import type { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {
TerminalController,
TerminalCreateRequest,
TerminalEnvironment,
TerminalFrame,
TerminalInput,
TerminalRetentionFrame,
TerminalShell,
WebTerminalId,
WebTerminalInfo,
} from '@deepseek-ai/dsh-api-terminal-controller'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { RemoteTerminals } from './terminal.ts'

/** The gateway invocation fields the stock attach path uses. */
interface Invocation {
uplink?<T>(): AsyncIterable<T>
}

/** Install the mirrored-Session gateway routing; returns its restorer. */
export function installRemoteTerminalController(
ctx: Context,
controller: TerminalController,
terminals: RemoteTerminals,
): () => void {
const original = {
environment: controller.environment.bind(controller),
shells: controller.shells.bind(controller),
list: controller.list.bind(controller),
create: controller.create.bind(controller),
retain: controller.retain.bind(controller),
attach: controller.attach.bind(controller),
rename: controller.rename.bind(controller),
close: controller.close.bind(controller),
}
const isMirror = (agent: Agent): boolean => terminals.isMirrored(String(agent.id))
const isMirrorSession = (sessionId: SessionId): boolean => terminals.isMirrored(String(sessionId))

controller.environment = (agent: Agent, signal: AbortSignal): TerminalEnvironment => {
signal.throwIfAborted()
return isMirror(agent) ? terminals.environment(String(agent.id)) : original.environment(agent, signal)
}
controller.shells = (agent: Agent, signal: AbortSignal): Promise<TerminalShell[]> => {
signal.throwIfAborted()
return isMirror(agent) ? Promise.resolve(terminals.shells()) : original.shells(agent, signal)
}
controller.list = ((sessionId: SessionId): WebTerminalInfo[] | Promise<WebTerminalInfo[]> =>
isMirrorSession(sessionId) ? terminals.list(String(sessionId)) : original.list(sessionId)) as TerminalController['list']
controller.create = (agent: Agent, request: TerminalCreateRequest, signal: AbortSignal): Promise<WebTerminalInfo> =>
isMirror(agent) ? terminals.create(String(agent.id), request) : original.create(agent, request, signal)
controller.retain = (sessionId: SessionId, id: WebTerminalId, signal: AbortSignal): AsyncIterable<TerminalRetentionFrame> => {
if (!isMirrorSession(sessionId)) return original.retain(sessionId, id, signal)
return (async function* () {
signal.throwIfAborted()
yield { type: 'retained' as const }
})()
}
controller.attach = function (this: TerminalController, agent: Agent, id: WebTerminalId, signal: AbortSignal): AsyncIterable<TerminalFrame> {
if (!isMirror(agent)) return original.attach(agent, id, signal)
const invocation = (this as unknown as { ctx?: { invocation?: Invocation } }).ctx?.invocation
if (invocation === undefined || invocation.uplink === undefined) throw new Error('Terminal attachment requires a Remote invocation')
const uplink = <T>(): AsyncIterable<T> => invocation.uplink!<T>()
void (async () => {
try {
for await (const input of uplink<TerminalInput>()) {
await terminals.input(String(id), input)
}
} catch (error) {
ctx.logger.warn('dsh-remote-local: remote terminal uplink failed: %s', String(error))
}
})()
return terminals.attach(String(id), signal)
}
controller.rename = ((agent: Agent, id: WebTerminalId, title: string): void | Promise<void> =>
isMirror(agent) ? terminals.rename(String(id), title) : original.rename(agent, id, title)) as TerminalController['rename']
controller.close = ((agent: Agent, id: WebTerminalId): Promise<void> =>
isMirror(agent) ? terminals.close(String(id)) : original.close(agent, id)) as TerminalController['close']

return () => {
controller.environment = original.environment
controller.shells = original.shells
controller.list = original.list
controller.create = original.create
controller.retain = original.retain
controller.attach = original.attach
controller.rename = original.rename
controller.close = original.close
}
}
112 changes: 112 additions & 0 deletions packages/local-plugin/test/remote-terminal-controller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'

import { installRemoteTerminalController } from '../src/remote/terminal-controller.ts'

function fakeController() {
const calls: string[] = []
const info = {
id: 't1', title: 'T', shell: { path: '/bin/bash', name: 'bash', args: [] },
cwd: '/local', cols: 80, rows: 24, state: 'running' as const, exitCode: null,
}
const controller: any = {
ctx: {},
environment(agent: any) {
calls.push(`local:environment:${agent.id}`)
return { cwd: '/local', maxInputBytes: 1, maxBufferedBytes: 1, maxCols: 80, maxRows: 24, scrollback: 0 }
},
shells() { calls.push('local:shells'); return Promise.resolve([]) },
list() { calls.push('local:list'); return [] },
create() { calls.push('local:create'); return Promise.resolve(info) },
retain() { calls.push('local:retain'); return (async function* () { yield { type: 'retained' } })() },
attach() { calls.push('local:attach'); return (async function* () { yield { type: 'state', sequence: 0, info, writable: true } })() },
rename() { calls.push('local:rename') },
close() { calls.push('local:close'); return Promise.resolve() },
}
return { controller, calls, info }
}

function fakeTerminals() {
const calls: string[] = []
const inputs: any[] = []
const info = {
id: 't1', title: 'T', shell: { path: '/bin/bash', name: 'Remote Bash', args: [] },
cwd: '/remote', cols: 80, rows: 24, state: 'running' as const, exitCode: null,
}
const terminals: any = {
isMirrored: (id: string) => id === 'mirror',
environment: (id: string) => {
calls.push(`remote:environment:${id}`)
return { cwd: '/remote', maxInputBytes: 1, maxBufferedBytes: 1, maxCols: 80, maxRows: 24, scrollback: 0 }
},
shells: () => { calls.push('remote:shells'); return [{ path: '/bin/bash', name: 'Remote Bash', args: [] }] },
list: async (id: string) => { calls.push(`remote:list:${id}`); return [info] },
create: async (id: string, request: any) => { calls.push(`remote:create:${id}:${request.id}`); return info },
input: async (id: string, input: any) => { calls.push(`remote:input:${id}`); inputs.push(input) },
rename: async (id: string, title: string) => { calls.push(`remote:rename:${id}:${title}`) },
close: async (id: string) => { calls.push(`remote:close:${id}`) },
attach: async function* (id: string) {
calls.push(`remote:attach:${id}`)
yield { type: 'state', sequence: 0, info, writable: true }
},
}
return { terminals, calls, inputs, info }
}

describe('gateway terminal routing', () => {
it('routes mirrored Sessions to the remote terminal manager and keeps local Sessions local', async () => {
const { controller, calls: localCalls } = fakeController()
const { terminals, calls: remoteCalls, inputs, info } = fakeTerminals()
const warnings: string[] = []
const ctx = { logger: { warn: (...args: unknown[]) => { warnings.push(args.join(' ')) } } }
const restore = installRemoteTerminalController(ctx as never, controller as never, terminals as never)
const signal = new AbortController().signal
const mirror = { id: 'mirror' }
const local = { id: 'local' }

assert.equal(controller.environment(mirror, signal).cwd, '/remote')
assert.deepEqual(await controller.shells(mirror, signal), [{ path: '/bin/bash', name: 'Remote Bash', args: [] }])
assert.deepEqual(await controller.list('mirror'), [info])
assert.deepEqual(await controller.create(mirror, { id: 't1', cols: 80, rows: 24 }, signal), info)

const retained: any[] = []
for await (const frame of controller.retain('mirror', 't1', signal)) retained.push(frame)
assert.deepEqual(retained, [{ type: 'retained' }])

controller.ctx = {
invocation: {
uplink: () => (async function* () {
yield { type: 'write', sequence: 1, data: 'x' }
})(),
},
}
const frames: any[] = []
for await (const frame of controller.attach(mirror, 't1', signal)) frames.push(frame)
await new Promise(resolve => setTimeout(resolve, 0))
assert.equal(frames.length, 1)
assert.deepEqual(inputs, [{ type: 'write', sequence: 1, data: 'x' }])

await controller.rename(mirror, 't1', 'renamed')
await controller.close(mirror, 't1')
assert.deepEqual(remoteCalls, [
'remote:environment:mirror',
'remote:shells',
'remote:list:mirror',
'remote:create:mirror:t1',
'remote:attach:t1',
'remote:input:t1',
'remote:rename:t1:renamed',
'remote:close:t1',
])
assert.deepEqual(localCalls, [])

assert.deepEqual(controller.environment(local, signal).cwd, '/local')
assert.deepEqual(await controller.shells(local, signal), [])
assert.deepEqual(controller.list('local'), [])
assert.deepEqual(localCalls, ['local:environment:local', 'local:shells', 'local:list'])
assert.deepEqual(warnings, [])

restore()
assert.deepEqual(controller.environment(local, signal).cwd, '/local')
})
})
1 change: 1 addition & 0 deletions packages/terminal-controller/lib/typert.host.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export declare const TYPERT: unknown
3 changes: 3 additions & 0 deletions packages/terminal-controller/lib/typert.host.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { TYPERT as STOCK } from '@deepseek-ai/dsh-api-terminal-controller/typert'

export const TYPERT = { ...STOCK, package: 'dsh-remote-terminal-controller' }
7 changes: 6 additions & 1 deletion packages/terminal-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
"exports": {
".": "./lib/index.js",
"./client": "./lib/client.js",
"./typert": {
"types": "./lib/typert.host.d.ts",
"default": "./lib/typert.host.js"
},
"./package.json": "./package.json"
},
"files": [
Expand All @@ -30,7 +34,8 @@
}
},
"scripts": {
"build": "node scripts/build.mjs"
"build": "node scripts/build.mjs",
"test": "node scripts/build.mjs && node --import tsx --test test/*.test.ts"
},
"peerDependencies": {
"@deepseek-ai/dsh-api-terminal-controller": ">=0.2.1-rc.1 <0.3.0-0"
Expand Down
8 changes: 8 additions & 0 deletions packages/terminal-controller/scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,12 @@ await writeFile(join(here, '..', 'lib', 'client.js'), output)
await writeFile(join(here, '..', 'lib', 'index.js'), `// Host half: keep the stock TerminalController implementation unchanged. The
// client half is what routes local versus mirrored Sessions.
export { TerminalController as default, TerminalController } from '@deepseek-ai/dsh-api-terminal-controller'\n`)
// The stock row is disabled, so this package must own the host Typert face or
// the gateway loses every terminal endpoint. Re-own the stock manifest because
// the loader requires TYPERT.package to equal the exporting package name.
await writeFile(join(here, '..', 'lib', 'typert.host.js'), `import { TYPERT as STOCK } from '@deepseek-ai/dsh-api-terminal-controller/typert'

export const TYPERT = { ...STOCK, package: 'dsh-remote-terminal-controller' }
`)
await writeFile(join(here, '..', 'lib', 'typert.host.d.ts'), 'export declare const TYPERT: unknown\n')
process.stdout.write('built dsh-remote-terminal-controller client\n')
52 changes: 52 additions & 0 deletions packages/terminal-controller/test/typert.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { describe, it } from 'node:test'

import { TYPERT as RAW } from '../lib/typert.host.js'

const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
readonly name: string
readonly exports: Record<string, { readonly default?: string }>
readonly files: readonly string[]
}

interface Invocation {
readonly service: string
readonly method: string
readonly mode?: string
}

interface Manifest {
readonly package: string
readonly face: string
readonly invocations: readonly Invocation[]
readonly model: {
readonly services: readonly { readonly key: string }[]
}
}

const TYPERT = RAW as Manifest

describe('terminal-controller host face', () => {
it('re-owns the stock terminal manifest for the fork package', () => {
assert.equal(TYPERT.package, 'dsh-remote-terminal-controller')
assert.equal(TYPERT.face, 'host')
assert.equal(TYPERT.model.services[0]?.key, 'terminalController')
})

it('exports the host face the typert loader discovers', () => {
assert.equal(packageJson.name, 'dsh-remote-terminal-controller')
assert.equal(packageJson.exports['./typert']?.default, './lib/typert.host.js')
assert.equal(TYPERT.package, packageJson.name)
assert.ok(packageJson.files.includes('lib'))
})

it('keeps every gateway terminal endpoint', () => {
const methods = new Set(TYPERT.invocations.map(invocation => invocation.method))
for (const method of ['attach', 'close', 'create', 'environment', 'list', 'rename', 'retain', 'shells']) {
assert.ok(methods.has(method), `missing terminal/${method}`)
}
assert.ok(TYPERT.invocations.some(invocation => invocation.method === 'attach' && invocation.mode === 'stream'))
assert.ok(TYPERT.invocations.some(invocation => invocation.method === 'retain' && invocation.mode === 'stream'))
})
})
1 change: 1 addition & 0 deletions scripts/release-plugin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ for (const required of [
join(pluginDir, 'assets'),
join(terminalDir, 'lib', 'index.js'),
join(terminalDir, 'lib', 'client.js'),
join(terminalDir, 'lib', 'typert.host.js'),
]) {
if (!existsSync(required)) throw new Error(`release-plugin: missing build output ${required}; run pnpm -r build first`)
}
Expand Down
Loading