feat(agent-core-v2): add session-scoped terminal domain

- add ITerminalService (create/list/get/attach/write/resize/close) and
  ITerminalBackend contract; Session-scoped so per-session sessionId
  threading is dropped
- implement TerminalService with per-terminal output buffering, seq replay,
  sink fan-out, and dispose-time process cleanup
- register a default NotImplementedTerminalBackend; the real PTY backend is
  supplied by the composition root
- add the terminal.not_found error code (protocol + ErrorCodes) and register
  the terminal layer in check-domain-layers
- cover the service with unit tests driven by a fake backend
This commit is contained in:
haozhe.yang 2026-06-29 05:15:01 +08:00
parent 0875c23014
commit 262d201b00
11 changed files with 652 additions and 0 deletions

View file

@ -43,6 +43,7 @@ package "Session scope (per session)" #EAFAF1 {
rectangle "<b>session-activity</b>\n<size:9><i>Session</i></size>\n ISessionActivity" as session_activity #D5F5E3
rectangle "<b>subagentHost</b>\n<size:9><i>Session</i></size>\n ISubagentHost" as subagentHost #D5F5E3
rectangle "<b>process</b>\n<size:9><i>Session</i></size>\n IProcessRunner\n IProcessBackend" as process #D5F5E3
rectangle "<b>terminal</b>\n<size:9><i>Session</i></size>\n ITerminalService\n ITerminalBackend" as terminal #D5F5E3
rectangle "<b>agent-lifecycle</b>\n<size:9><i>Session</i></size>\n IAgentLifecycleService" as agent_lifecycle #D5F5E3
rectangle "<b>session-context</b>\n<size:9><i>Session</i></size>\n ISessionContext (seed)" as session_context #D5F5E3
}
@ -107,6 +108,8 @@ sessionIndex --> bootstrap #34495E
agentFs --> workspaceContext #34495E
agentFs --> hostFs #34495E
process --> workspaceContext #34495E
terminal --> workspaceContext #34495E
terminal --> session_context #34495E
hostFolderBrowser --> hostFs #34495E
auth --> provider #34495E
auth --> environment #34495E

View file

@ -114,6 +114,7 @@ const DOMAIN_LAYER = new Map([
['session-metadata', 6],
['session-activity', 6],
['session', 6],
['terminal', 6],
// L7 — boundary
['event', 7],
['approval', 7],

View file

@ -22,6 +22,7 @@ import { ProfileErrors } from '#/profile/errors';
import { PromptErrors } from '#/prompt/errors';
import { SessionErrors } from '#/session/errors';
import { SkillErrors } from '#/skill/errors';
import { TerminalErrors } from '#/terminal/errors';
import { TurnErrors } from '#/turn/errors';
import { WireRecordErrors } from '#/wireRecord/errors';
@ -40,6 +41,7 @@ export { ProfileErrors } from '#/profile/errors';
export { PromptErrors } from '#/prompt/errors';
export { SessionErrors } from '#/session/errors';
export { SkillErrors } from '#/skill/errors';
export { TerminalErrors } from '#/terminal/errors';
export { TurnErrors } from '#/turn/errors';
export { WireRecordErrors } from '#/wireRecord/errors';
@ -59,6 +61,7 @@ export const ErrorCodes = {
...PromptErrors.codes,
...SessionErrors.codes,
...SkillErrors.codes,
...TerminalErrors.codes,
...TurnErrors.codes,
...WireRecordErrors.codes,
} as const;

View file

@ -51,6 +51,7 @@ export * from './workspaceRegistry/index';
export * from './hostFolderBrowser/index';
export * from './agentFs/index';
export * from './process/index';
export * from './terminal/index';
export * from './storage/index';
export * from './auth/index';

View file

@ -0,0 +1,13 @@
/**
* `terminal` domain error codes.
*/
import { registerErrorDomain, type ErrorDomain } from '#/_base/errors';
export const TerminalErrors = {
codes: {
TERMINAL_NOT_FOUND: 'terminal.not_found',
},
} as const satisfies ErrorDomain;
registerErrorDomain(TerminalErrors);

View file

@ -0,0 +1,12 @@
/**
* `terminal` domain barrel re-exports the terminal contract (`terminal`),
* its scoped service (`terminalService`), the default backend stub
* (`terminalBackend`), and the domain error codes (`errors`). Importing this
* barrel registers the `ITerminalService` and default `ITerminalBackend`
* bindings into the scope registry.
*/
export * from './terminal';
export * from './errors';
export * from './terminalService';
export * from './terminalBackend';

View file

@ -0,0 +1,88 @@
/**
* `terminal` domain (L6) interactive terminal (PTY) contract.
*
* Defines the `ITerminalService` that business code (and the edge, via an
* accessor borrow) uses to manage a session's interactive terminals, the
* `ITerminalBackend` provider that hides the local/ssh/container split, and
* the attach/stream types (`TerminalProcess`, `TerminalAttachSink`,
* `TerminalFrame`) used to wire terminal I/O to a transport. Session-scoped:
* one `ITerminalService` owns only its own session's terminals. Wire types
* (`Terminal`, `CreateTerminalRequest`, frame messages) are sourced from
* `@moonshot-ai/protocol`.
*/
import type {
CreateTerminalRequest,
Terminal,
TerminalExitMessage,
TerminalOutputMessage,
} from '@moonshot-ai/protocol';
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { Event } from '#/_base/event';
export type { CreateTerminalRequest, Terminal, TerminalExitMessage, TerminalOutputMessage };
export type TerminalFrame = TerminalOutputMessage | TerminalExitMessage;
export interface TerminalAttachSink {
readonly id: string;
send(frame: TerminalFrame): void;
}
export interface TerminalAttachOptions {
readonly sinceSeq?: number;
}
export interface TerminalSpawnOptions {
readonly cwd: string;
readonly shell: string;
readonly cols: number;
readonly rows: number;
}
export interface TerminalProcess {
readonly onData: Event<string>;
readonly onExit: Event<{ exitCode: number | null }>;
write(data: string): void;
resize(cols: number, rows: number): void;
kill(): void;
}
export interface ITerminalService {
readonly _serviceBrand: undefined;
create(input: CreateTerminalRequest): Promise<Terminal>;
list(): Promise<readonly Terminal[]>;
get(terminalId: string): Promise<Terminal>;
attach(
terminalId: string,
sink: TerminalAttachSink,
options?: TerminalAttachOptions,
): Promise<{ replayed: number }>;
detach(terminalId: string, sinkId: string): void;
detachAllForSink(sinkId: string): void;
write(terminalId: string, data: string): Promise<void>;
resize(terminalId: string, cols: number, rows: number): Promise<void>;
close(terminalId: string): Promise<{ closed: true }>;
}
export const ITerminalService: ServiceIdentifier<ITerminalService> =
createDecorator<ITerminalService>('terminalService');
export interface ITerminalBackend {
readonly _serviceBrand: undefined;
spawn(options: TerminalSpawnOptions): Promise<TerminalProcess>;
}
export const ITerminalBackend: ServiceIdentifier<ITerminalBackend> =
createDecorator<ITerminalBackend>('terminalBackend');

View file

@ -0,0 +1,31 @@
/**
* `terminal` domain (L6) default `ITerminalBackend` stub.
*
* Placeholder backend registered so the binding graph is complete and
* `ITerminalService` resolves out of the box. It cannot spawn a real PTY; a
* composition root that needs interactive terminals (for example the server
* or the desktop app, both of which already depend on `node-pty`) supplies a
* real backend through the scope registry to override this one.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { NotImplementedError } from '#/_base/errors';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { type TerminalProcess, type TerminalSpawnOptions, ITerminalBackend } from './terminal';
export class NotImplementedTerminalBackend implements ITerminalBackend {
declare readonly _serviceBrand: undefined;
spawn(_options: TerminalSpawnOptions): Promise<TerminalProcess> {
throw new NotImplementedError('terminalBackend');
}
}
registerScopedService(
LifecycleScope.Session,
ITerminalBackend,
NotImplementedTerminalBackend,
InstantiationType.Delayed,
'terminal',
);

View file

@ -0,0 +1,246 @@
/**
* `terminal` domain (L6) `ITerminalService` implementation.
*
* Owns this session's terminal set and its per-terminal output buffers and
* attached sinks; spawns PTYs through the injected `ITerminalBackend`,
* resolves the working directory through `workspaceContext`, and reads the
* session id through `session-context` to tag frames. Bound at Session scope.
*/
import { randomUUID } from 'node:crypto';
import { InstantiationType } from '#/_base/di/extensions';
import { Disposable, type IDisposable } from '#/_base/di/lifecycle';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { ErrorCodes, KimiError } from '#/errors';
import { ISessionContext } from '#/session-context';
import { IWorkspaceContext } from '#/workspaceContext';
import {
type CreateTerminalRequest,
type Terminal,
type TerminalAttachOptions,
type TerminalAttachSink,
type TerminalExitMessage,
type TerminalFrame,
type TerminalOutputMessage,
type TerminalProcess,
ITerminalBackend,
ITerminalService,
} from './terminal';
const DEFAULT_COLS = 80;
const DEFAULT_ROWS = 24;
const DEFAULT_MAX_BUFFERED_FRAMES = 2000;
interface TerminalRecord {
terminal: Terminal;
process: TerminalProcess;
sinks: Map<string, TerminalAttachSink>;
buffer: TerminalFrame[];
nextSeq: number;
disposables: IDisposable[];
closed: boolean;
}
export class TerminalService extends Disposable implements ITerminalService {
declare readonly _serviceBrand: undefined;
private readonly records = new Map<string, TerminalRecord>();
constructor(
@ITerminalBackend private readonly backend: ITerminalBackend,
@IWorkspaceContext private readonly workspace: IWorkspaceContext,
@ISessionContext private readonly sessionContext: ISessionContext,
) {
super();
}
async create(input: CreateTerminalRequest): Promise<Terminal> {
const cwd =
input.cwd === undefined
? this.workspace.workDir
: this.workspace.assertAllowed(input.cwd, 'execute');
const shell = input.shell ?? defaultShell();
const cols = input.cols ?? DEFAULT_COLS;
const rows = input.rows ?? DEFAULT_ROWS;
const process = await this.backend.spawn({ cwd, shell, cols, rows });
const terminal: Terminal = {
id: `term_${randomUUID()}`,
session_id: this.sessionContext.sessionId,
cwd,
shell,
cols,
rows,
status: 'running',
created_at: new Date().toISOString(),
};
const record: TerminalRecord = {
terminal,
process,
sinks: new Map(),
buffer: [],
nextSeq: 0,
disposables: [],
closed: false,
};
record.disposables.push(
process.onData((data) => this.onData(record, data)),
process.onExit((event) => this.onExit(record, event.exitCode)),
);
this.records.set(terminal.id, record);
return { ...terminal };
}
list(): Promise<readonly Terminal[]> {
return Promise.resolve(
[...this.records.values()].map((record) => ({ ...record.terminal })),
);
}
async get(terminalId: string): Promise<Terminal> {
return { ...this.requireRecord(terminalId).terminal };
}
async attach(
terminalId: string,
sink: TerminalAttachSink,
options: TerminalAttachOptions = {},
): Promise<{ replayed: number }> {
const record = this.requireRecord(terminalId);
record.sinks.set(sink.id, sink);
const sinceSeq = options.sinceSeq ?? 0;
const replay = record.buffer.filter((frame) => frameSeq(frame) > sinceSeq);
for (const frame of replay) {
sink.send(frame);
}
return { replayed: replay.length };
}
detach(terminalId: string, sinkId: string): void {
this.records.get(terminalId)?.sinks.delete(sinkId);
}
detachAllForSink(sinkId: string): void {
for (const record of this.records.values()) {
record.sinks.delete(sinkId);
}
}
async write(terminalId: string, data: string): Promise<void> {
const record = this.requireRecord(terminalId);
record.process.write(data);
}
async resize(terminalId: string, cols: number, rows: number): Promise<void> {
const record = this.requireRecord(terminalId);
record.terminal = { ...record.terminal, cols, rows };
record.process.resize(cols, rows);
}
async close(terminalId: string): Promise<{ closed: true }> {
const record = this.requireRecord(terminalId);
if (!record.closed) {
record.closed = true;
record.process.kill();
this.markExited(record, null);
}
return { closed: true };
}
override dispose(): void {
for (const record of this.records.values()) {
disposeAll(record.disposables);
try {
record.process.kill();
} catch {
// best-effort cleanup
}
}
this.records.clear();
super.dispose();
}
private requireRecord(terminalId: string): TerminalRecord {
const record = this.records.get(terminalId);
if (record === undefined) {
throw new KimiError(
ErrorCodes.TERMINAL_NOT_FOUND,
`terminal ${terminalId} does not exist in session ${this.sessionContext.sessionId}`,
);
}
return record;
}
private onData(record: TerminalRecord, data: string): void {
const frame: TerminalOutputMessage = {
type: 'terminal_output',
seq: ++record.nextSeq,
session_id: record.terminal.session_id,
terminal_id: record.terminal.id,
timestamp: new Date().toISOString(),
payload: { data },
};
this.pushFrame(record, frame);
}
private onExit(record: TerminalRecord, exitCode: number | null): void {
this.markExited(record, exitCode);
}
private markExited(record: TerminalRecord, exitCode: number | null): void {
if (record.terminal.status === 'exited') return;
record.closed = true;
record.terminal = {
...record.terminal,
status: 'exited',
exited_at: new Date().toISOString(),
exit_code: exitCode,
};
const frame: TerminalExitMessage = {
type: 'terminal_exit',
session_id: record.terminal.session_id,
terminal_id: record.terminal.id,
timestamp: new Date().toISOString(),
payload: { exit_code: exitCode },
};
this.pushFrame(record, frame);
disposeAll(record.disposables);
record.disposables = [];
}
private pushFrame(record: TerminalRecord, frame: TerminalFrame): void {
record.buffer.push(frame);
if (record.buffer.length > DEFAULT_MAX_BUFFERED_FRAMES) {
record.buffer.splice(0, record.buffer.length - DEFAULT_MAX_BUFFERED_FRAMES);
}
for (const sink of record.sinks.values()) {
sink.send(frame);
}
}
}
function disposeAll(items: Iterable<IDisposable>): void {
for (const item of items) {
item.dispose();
}
}
function frameSeq(frame: TerminalFrame): number {
return frame.type === 'terminal_output' ? frame.seq : Number.MAX_SAFE_INTEGER;
}
function defaultShell(): string {
// Use `||` (not `??`): an EMPTY $SHELL (set but blank, as some daemon/launchd
// envs leave it) must still fall back, or a PTY spawn fails with
// "posix_spawnp failed".
return process.env['SHELL'] || '/bin/sh';
}
registerScopedService(
LifecycleScope.Session,
ITerminalService,
TerminalService,
InstantiationType.Delayed,
'terminal',
);

View file

@ -0,0 +1,252 @@
import { resolve } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DisposableStore } from '#/_base/di/lifecycle';
import { createServices, type TestInstantiationService } from '#/_base/di/test';
import { Emitter } from '#/_base/event';
import { ErrorCodes } from '#/errors';
import { ISessionContext } from '#/session-context';
import {
type TerminalAttachSink,
type TerminalFrame,
type TerminalProcess,
type TerminalSpawnOptions,
ITerminalBackend,
ITerminalService,
} from '#/terminal';
import { TerminalService } from '#/terminal/terminalService';
import { IWorkspaceContext } from '#/workspaceContext';
class FakeTerminalProcess implements TerminalProcess {
private readonly dataEmitter = new Emitter<string>();
private readonly exitEmitter = new Emitter<{ exitCode: number | null }>();
readonly onData = this.dataEmitter.event;
readonly onExit = this.exitEmitter.event;
readonly writes: string[] = [];
readonly resizes: Array<[number, number]> = [];
killed = false;
write(data: string): void {
this.writes.push(data);
}
resize(cols: number, rows: number): void {
this.resizes.push([cols, rows]);
}
kill(): void {
this.killed = true;
}
emitData(data: string): void {
this.dataEmitter.fire(data);
}
emitExit(exitCode: number | null): void {
this.exitEmitter.fire({ exitCode });
}
dispose(): void {
this.dataEmitter.dispose();
this.exitEmitter.dispose();
}
}
class FakeTerminalBackend implements ITerminalBackend {
declare readonly _serviceBrand: undefined;
readonly processes: FakeTerminalProcess[] = [];
readonly lastOptions: TerminalSpawnOptions[] = [];
spawn(options: TerminalSpawnOptions): Promise<TerminalProcess> {
this.lastOptions.push(options);
const proc = new FakeTerminalProcess();
this.processes.push(proc);
return Promise.resolve(proc);
}
}
function stubWorkspace(workDir = '/ws'): IWorkspaceContext {
return {
_serviceBrand: undefined,
workDir,
additionalDirs: [],
setWorkDir: () => {},
resolve: (rel) => resolve(workDir, rel),
isWithin: () => true,
assertAllowed: (absPath) => resolve(workDir, absPath),
addAdditionalDir: () => {},
removeAdditionalDir: () => {},
};
}
function stubSessionContext(sessionId = 's1'): ISessionContext {
return {
_serviceBrand: undefined,
sessionId,
workspaceId: 'w1',
sessionDir: '/ws/.session',
metaScope: `session:${sessionId}`,
};
}
function collectSink(id = 'sink-1'): { sink: TerminalAttachSink; frames: TerminalFrame[] } {
const frames: TerminalFrame[] = [];
return { sink: { id, send: (frame) => frames.push(frame) }, frames };
}
describe('TerminalService', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
let backend: FakeTerminalBackend;
beforeEach(() => {
disposables = new DisposableStore();
backend = new FakeTerminalBackend();
ix = createServices(disposables, {
additionalServices: (reg) => {
reg.define(ITerminalService, TerminalService);
reg.defineInstance(ITerminalBackend, backend);
reg.defineInstance(IWorkspaceContext, stubWorkspace());
reg.defineInstance(ISessionContext, stubSessionContext());
},
});
});
afterEach(() => disposables.dispose());
it('creates a terminal and resolves cwd through the workspace', async () => {
const svc = ix.get(ITerminalService);
const terminal = await svc.create({ cwd: 'sub', cols: 100, rows: 40 });
expect(terminal.status).toBe('running');
expect(terminal.session_id).toBe('s1');
expect(terminal.cwd).toBe(resolve('/ws', 'sub'));
expect(terminal.cols).toBe(100);
expect(terminal.rows).toBe(40);
expect(backend.processes).toHaveLength(1);
expect(backend.lastOptions[0]?.cwd).toBe(resolve('/ws', 'sub'));
});
it('uses the workspace workDir when cwd is omitted', async () => {
const svc = ix.get(ITerminalService);
const terminal = await svc.create({});
expect(terminal.cwd).toBe('/ws');
expect(terminal.cols).toBe(80);
expect(terminal.rows).toBe(24);
});
it('lists and gets terminals', async () => {
const svc = ix.get(ITerminalService);
const created = await svc.create({});
const listed = await svc.list();
expect(listed).toHaveLength(1);
expect(listed[0]?.id).toBe(created.id);
const fetched = await svc.get(created.id);
expect(fetched.id).toBe(created.id);
});
it('throws TERMINAL_NOT_FOUND for an unknown terminal', async () => {
const svc = ix.get(ITerminalService);
await expect(svc.get('nope')).rejects.toMatchObject({
code: ErrorCodes.TERMINAL_NOT_FOUND,
});
});
it('attaches a sink, replays buffered frames, then streams live output', async () => {
const svc = ix.get(ITerminalService);
const terminal = await svc.create({});
const proc = backend.processes[0]!;
proc.emitData('hello');
const { sink, frames } = collectSink();
const { replayed } = await svc.attach(terminal.id, sink);
expect(replayed).toBe(1);
expect(frames).toHaveLength(1);
expect(frames[0]).toMatchObject({
type: 'terminal_output',
seq: 1,
session_id: 's1',
terminal_id: terminal.id,
payload: { data: 'hello' },
});
proc.emitData('world');
expect(frames).toHaveLength(2);
expect(frames[1]).toMatchObject({ type: 'terminal_output', seq: 2, payload: { data: 'world' } });
});
it('replays only frames after sinceSeq', async () => {
const svc = ix.get(ITerminalService);
const terminal = await svc.create({});
const proc = backend.processes[0]!;
proc.emitData('a');
proc.emitData('b');
proc.emitData('c');
const { sink, frames } = collectSink();
const { replayed } = await svc.attach(terminal.id, sink, { sinceSeq: 1 });
expect(replayed).toBe(2);
expect(frames.map((f) => (f as { seq?: number }).seq)).toEqual([2, 3]);
});
it('emits an exit frame and marks the terminal exited on process exit', async () => {
const svc = ix.get(ITerminalService);
const terminal = await svc.create({});
const proc = backend.processes[0]!;
const { sink, frames } = collectSink();
await svc.attach(terminal.id, sink);
proc.emitExit(7);
const exitFrame = frames.find((f) => f.type === 'terminal_exit');
expect(exitFrame).toMatchObject({
type: 'terminal_exit',
terminal_id: terminal.id,
payload: { exit_code: 7 },
});
const fetched = await svc.get(terminal.id);
expect(fetched.status).toBe('exited');
expect(fetched.exit_code).toBe(7);
});
it('delegates write and resize to the process', async () => {
const svc = ix.get(ITerminalService);
const terminal = await svc.create({});
const proc = backend.processes[0]!;
await svc.write(terminal.id, 'ls\n');
await svc.resize(terminal.id, 120, 50);
expect(proc.writes).toEqual(['ls\n']);
expect(proc.resizes).toEqual([[120, 50]]);
expect((await svc.get(terminal.id)).cols).toBe(120);
});
it('closes a terminal by killing the process and marking it exited', async () => {
const svc = ix.get(ITerminalService);
const terminal = await svc.create({});
const proc = backend.processes[0]!;
const result = await svc.close(terminal.id);
expect(result).toEqual({ closed: true });
expect(proc.killed).toBe(true);
expect((await svc.get(terminal.id)).status).toBe('exited');
});
it('detaches a sink so it stops receiving frames', async () => {
const svc = ix.get(ITerminalService);
const terminal = await svc.create({});
const proc = backend.processes[0]!;
const { sink, frames } = collectSink();
await svc.attach(terminal.id, sink);
svc.detach(terminal.id, sink.id);
proc.emitData('after-detach');
expect(frames).toHaveLength(0);
});
it('kills every live process when the service is disposed', async () => {
const svc = ix.get(ITerminalService);
await svc.create({});
const proc = backend.processes[0]!;
disposables.dispose();
expect(proc.killed).toBe(true);
});
});

View file

@ -224,6 +224,7 @@ export type KimiErrorCode =
| 'request.prompt_input_empty'
| 'shell.git_bash_not_found'
| 'workspace.not_found'
| 'terminal.not_found'
| 'fs.path_not_found'
| 'fs.permission_denied'
| 'validation.failed'
@ -855,6 +856,7 @@ export const kimiErrorCodeSchema = z.enum([
'request.prompt_input_empty',
'shell.git_bash_not_found',
'workspace.not_found',
'terminal.not_found',
'fs.path_not_found',
'fs.permission_denied',
'validation.failed',