diff --git a/packages/agent-core-v2/docs/di-scope-domains.puml b/packages/agent-core-v2/docs/di-scope-domains.puml
index 55d7bdae0..8e2cfefc5 100644
--- a/packages/agent-core-v2/docs/di-scope-domains.puml
+++ b/packages/agent-core-v2/docs/di-scope-domains.puml
@@ -43,6 +43,7 @@ package "Session scope (per session)" #EAFAF1 {
rectangle "session-activity\nSession\n ISessionActivity" as session_activity #D5F5E3
rectangle "subagentHost\nSession\n ISubagentHost" as subagentHost #D5F5E3
rectangle "process\nSession\n IProcessRunner\n IProcessBackend" as process #D5F5E3
+ rectangle "terminal\nSession\n ITerminalService\n ITerminalBackend" as terminal #D5F5E3
rectangle "agent-lifecycle\nSession\n IAgentLifecycleService" as agent_lifecycle #D5F5E3
rectangle "session-context\nSession\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
diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs
index f6b66d4cc..3c5dde132 100644
--- a/packages/agent-core-v2/scripts/check-domain-layers.mjs
+++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs
@@ -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],
diff --git a/packages/agent-core-v2/src/errors.ts b/packages/agent-core-v2/src/errors.ts
index deb9fc327..4af35fba1 100644
--- a/packages/agent-core-v2/src/errors.ts
+++ b/packages/agent-core-v2/src/errors.ts
@@ -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;
diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts
index 20b49065e..d2465287b 100644
--- a/packages/agent-core-v2/src/index.ts
+++ b/packages/agent-core-v2/src/index.ts
@@ -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';
diff --git a/packages/agent-core-v2/src/terminal/errors.ts b/packages/agent-core-v2/src/terminal/errors.ts
new file mode 100644
index 000000000..578c21902
--- /dev/null
+++ b/packages/agent-core-v2/src/terminal/errors.ts
@@ -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);
diff --git a/packages/agent-core-v2/src/terminal/index.ts b/packages/agent-core-v2/src/terminal/index.ts
new file mode 100644
index 000000000..03d721b4b
--- /dev/null
+++ b/packages/agent-core-v2/src/terminal/index.ts
@@ -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';
diff --git a/packages/agent-core-v2/src/terminal/terminal.ts b/packages/agent-core-v2/src/terminal/terminal.ts
new file mode 100644
index 000000000..992328e23
--- /dev/null
+++ b/packages/agent-core-v2/src/terminal/terminal.ts
@@ -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;
+ 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;
+
+ list(): Promise;
+
+ get(terminalId: string): Promise;
+
+ 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;
+
+ resize(terminalId: string, cols: number, rows: number): Promise;
+
+ close(terminalId: string): Promise<{ closed: true }>;
+}
+
+export const ITerminalService: ServiceIdentifier =
+ createDecorator('terminalService');
+
+export interface ITerminalBackend {
+ readonly _serviceBrand: undefined;
+
+ spawn(options: TerminalSpawnOptions): Promise;
+}
+
+export const ITerminalBackend: ServiceIdentifier =
+ createDecorator('terminalBackend');
diff --git a/packages/agent-core-v2/src/terminal/terminalBackend.ts b/packages/agent-core-v2/src/terminal/terminalBackend.ts
new file mode 100644
index 000000000..83d641e00
--- /dev/null
+++ b/packages/agent-core-v2/src/terminal/terminalBackend.ts
@@ -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 {
+ throw new NotImplementedError('terminalBackend');
+ }
+}
+
+registerScopedService(
+ LifecycleScope.Session,
+ ITerminalBackend,
+ NotImplementedTerminalBackend,
+ InstantiationType.Delayed,
+ 'terminal',
+);
diff --git a/packages/agent-core-v2/src/terminal/terminalService.ts b/packages/agent-core-v2/src/terminal/terminalService.ts
new file mode 100644
index 000000000..0b38cbfeb
--- /dev/null
+++ b/packages/agent-core-v2/src/terminal/terminalService.ts
@@ -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;
+ buffer: TerminalFrame[];
+ nextSeq: number;
+ disposables: IDisposable[];
+ closed: boolean;
+}
+
+export class TerminalService extends Disposable implements ITerminalService {
+ declare readonly _serviceBrand: undefined;
+
+ private readonly records = new Map();
+
+ constructor(
+ @ITerminalBackend private readonly backend: ITerminalBackend,
+ @IWorkspaceContext private readonly workspace: IWorkspaceContext,
+ @ISessionContext private readonly sessionContext: ISessionContext,
+ ) {
+ super();
+ }
+
+ async create(input: CreateTerminalRequest): Promise {
+ 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 {
+ return Promise.resolve(
+ [...this.records.values()].map((record) => ({ ...record.terminal })),
+ );
+ }
+
+ async get(terminalId: string): Promise {
+ 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 {
+ const record = this.requireRecord(terminalId);
+ record.process.write(data);
+ }
+
+ async resize(terminalId: string, cols: number, rows: number): Promise {
+ 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): 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',
+);
diff --git a/packages/agent-core-v2/test/terminal/terminalService.test.ts b/packages/agent-core-v2/test/terminal/terminalService.test.ts
new file mode 100644
index 000000000..f1a560fb4
--- /dev/null
+++ b/packages/agent-core-v2/test/terminal/terminalService.test.ts
@@ -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();
+ 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 {
+ 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);
+ });
+});
diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts
index e105b14a0..1318bdaee 100644
--- a/packages/protocol/src/events.ts
+++ b/packages/protocol/src/events.ts
@@ -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',