@hasna-internal/kai-terminal 0.1.1-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DeepSeek
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,6 @@
1
+ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
2
+ # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
+ # after editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write packages/terminal/terminal/README.md
5
+ README.md: e8959a1128575a00285a9d3b1165569d2dfbdc04
6
+ README.zh.md: 16a24ad94ea07f27927a9ac30fc026b13e084874
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # @hasna-internal/kai-terminal
2
+
3
+ English | [中文](README.zh.md)
4
+
5
+ Owner-scoped persistent PTY seam. `TerminalSessionService` registers as `ctx.terminals`, mints opaque session ids, routes creation through named backends, fences every operation to the exact live `Agent`, and awaits backend quiescence when that agent or the service disposes.
6
+
7
+ ## Contract
8
+
9
+ - Backends register one stable `type` and return an unpublished `TerminalBackendSession`; failed or cancelled setup must clean partial resources, and a failed cleanup rejects with `TerminalBackendCleanupError` so the registry can retain it across cancellation.
10
+ - Spawn cancellation preserves the caller's exact abort reason. Service disposal and owner loss remain distinct machine-routable failures after backend setup.
11
+ - Owner and service disposal abort unpublished setup through a service-owned signal and await backend settlement plus rollback before returning.
12
+ - A rollback-close or backend-reported startup cleanup failure rejects the disposing lifecycle instead of claiming quiescence. Caller-triggered cancellation still receives its exact reason; lifecycle-triggered rollback failure also rejects the pending spawn.
13
+ - A backend cleanup failure that follows caller cancellation remains owner activity until owner or service disposal consumes and reports it, so lifecycle policy cannot mistake failed cleanup for quiescence.
14
+ - `hasOwnerActivity(owner)` spans unpublished setup through final close, so lifecycle policy can fence the exact owner without a publication race.
15
+ - A successful spawn publishes one `TerminalSessionId`. The optional `name` is owner-local display metadata, never authority.
16
+ - One session accepts at most one live send operation. Reads and signals may observe it; another send fails until the operation settles.
17
+ - `TerminalSendResult.waitReason` and `sessionStatus` are independent. `session_exit` describes the top-level PTY process, not an arbitrary foreground command.
18
+ - `kill()` and disposal resolve only after the backend's captured process tree is quiescent. A cleanup failure rejects instead of claiming success and clears the matching backend and registry fences so a later close can retry without disturbing a newer attempt.
19
+
20
+ The seam contains no `node-pty`, sandbox, tool-schema, prompt, task, or terminal-rendering policy. Implementations own terminal mechanics; consumers own model presentation and optional background-job registration.
21
+
22
+ ## Model Experience
23
+
24
+ ### Indirect consumer
25
+
26
+ #### What the model sees
27
+
28
+ Nothing directly. This package registers no prompt or tool; `@hasna-internal/kai-tool-terminal` owns visible schemas and result text.
29
+
30
+ #### Token effect
31
+
32
+ None directly. Live session state stays process-local until a consumer returns a bounded result.
33
+
34
+ #### KV Cache effect
35
+
36
+ No direct invalidation; the named consumer owns request-prefix changes.
37
+
38
+ ## Known Limitations and Deferred Work
39
+
40
+ - Sessions are process-local and are not restored after a harness restart.
41
+ - Cross-agent sharing is intentionally absent; a future shared-session design needs a separate authority contract.
package/README.zh.md ADDED
@@ -0,0 +1,41 @@
1
+ # @hasna-internal/kai-terminal
2
+
3
+ [English](README.md) | 中文
4
+
5
+ 限定所有者范围的持久 PTY seam。`TerminalSessionService` 注册为 `ctx.terminals`,生成不透明的会话 id,通过具名后端路由创建操作,将每个操作限制在完全相同的活跃 `Agent` 内,并在该 agent(智能体)或服务 dispose(资源释放)时等待后端完全停稳。
6
+
7
+ ## 约定
8
+
9
+ - 后端注册一个稳定的 `type`,并返回尚未发布的 `TerminalBackendSession`;失败或取消的设置过程必须清理部分资源。若清理失败,则以 `TerminalBackendCleanupError` 拒绝,使注册表能在取消后继续保留该清理失败。
10
+ - spawn 取消会保留调用方提供的确切中止原因。后端设置完成后,服务 dispose 与所有者消失仍分别对应可供机器路由的不同失败。
11
+ - 所有者与服务的 dispose 会通过服务持有的信号中止尚未发布的设置,并等待后端结算和回滚后才返回。
12
+ - 如果回滚关闭失败,或后端报告启动清理失败,dispose 生命周期会以拒绝结束,不会声称已经完全停稳。调用方触发的取消仍收到其确切原因;生命周期触发的回滚失败也会拒绝待完成的 spawn。
13
+ - 调用方取消后发生的后端清理失败仍算作所有者活动,直到所有者或服务 dispose 并消费、报告该失败,避免生命周期策略把失败的清理误判为完全停稳。
14
+ - `hasOwnerActivity(owner)` 覆盖从尚未发布的设置到最终关闭的全过程,使生命周期策略能精确限制对应所有者,不受发布竞态影响。
15
+ - 成功的 spawn 会发布一个 `TerminalSessionId`。可选的 `name` 只是所有者本地的显示元数据,绝不代表权限。
16
+ - 一个会话最多接受一个活跃的发送操作。读取和信号操作可以观察该发送;在当前操作结算前,另一项发送会失败。
17
+ - `TerminalSendResult.waitReason` 与 `sessionStatus` 相互独立。`session_exit` 描述顶层 PTY 进程,而不是任意前台命令。
18
+ - `kill()` 与 dispose 只会在后端捕获的进程树完全停稳后完成。清理失败会以拒绝结束,而非声称成功;同时它会清除匹配的后端和注册表限制,使后续关闭能够重试,且不会干扰较新的尝试。
19
+
20
+ 该 seam 不包含 `node-pty`、沙箱、工具 schema、提示词、任务或终端渲染策略。实现负责终端机制;消费方负责模型呈现和可选的后台任务注册。
21
+
22
+ ## 模型体验
23
+
24
+ ### 间接消费方
25
+
26
+ #### 模型看到的内容
27
+
28
+ 没有直接可见内容。此包不注册提示词或工具;可见 schema 和结果文本由 `@hasna-internal/kai-tool-terminal` 负责。
29
+
30
+ #### Token 影响
31
+
32
+ 没有直接影响。活跃会话状态会保留在进程本地,直到消费方返回有界结果。
33
+
34
+ #### KV Cache 影响
35
+
36
+ 不会直接失效;请求前缀变更由上述消费方负责。
37
+
38
+ ## 已知限制与暂缓事项
39
+
40
+ - 会话只存在于进程本地,harness 重启后不会恢复。
41
+ - 系统有意不支持跨 agent 共享;未来的共享会话设计需要独立的权限约定。
package/lib/index.js ADDED
@@ -0,0 +1,360 @@
1
+ import { Service } from "@deepseek-ai/cordis";
2
+ //#region lib/types/types.js
3
+ /**
4
+ * Types shared by PTY backends, the owner-scoped registry, and tool consumers.
5
+ * Runtime service code lives in `./index.ts`.
6
+ * @module @hasna-internal/kai-terminal/types
7
+ */
8
+ /**
9
+ * Backend-reported failure to clean partial resources after unpublished setup failed.
10
+ * @param spawnError - original setup or cancellation failure.
11
+ * @param cleanupError - failure that may leave backend-owned resources alive.
12
+ */
13
+ var TerminalBackendCleanupError = class extends AggregateError {
14
+ spawnError;
15
+ cleanupError;
16
+ constructor(spawnError, cleanupError) {
17
+ super([spawnError, cleanupError], "PTY backend startup and cleanup both failed");
18
+ this.spawnError = spawnError;
19
+ this.cleanupError = cleanupError;
20
+ this.name = "TerminalBackendCleanupError";
21
+ }
22
+ };
23
+ //#endregion
24
+ //#region lib/types/index.js
25
+ /**
26
+ * Owner-scoped persistent PTY registry. Backends own terminal mechanics while
27
+ * this service owns ids, publication, authorization, and awaited cleanup.
28
+ * @module @hasna-internal/kai-terminal
29
+ */
30
+ /** Error carrying a stable {@link TerminalErrorCode}. */
31
+ var TerminalError = class extends Error {
32
+ code;
33
+ constructor(message, code) {
34
+ super(message);
35
+ this.code = code;
36
+ this.name = "TerminalError";
37
+ }
38
+ };
39
+ /**
40
+ * Brand one registry-minted string as a {@link TerminalSessionId}.
41
+ * @param value - raw registry-issued id.
42
+ * @returns Same string with the PTY session brand.
43
+ */
44
+ function TerminalSessionId(value) {
45
+ return value;
46
+ }
47
+ /** In-process registry for replaceable PTY backends and exact-Agent sessions. */
48
+ var TerminalSessionService = class extends Service {
49
+ backends = /* @__PURE__ */ new Map();
50
+ sessions = /* @__PURE__ */ new Map();
51
+ reservedNames = /* @__PURE__ */ new Map();
52
+ pendingSpawns = /* @__PURE__ */ new Map();
53
+ ownerCleanups = /* @__PURE__ */ new Map();
54
+ disposedOwners = /* @__PURE__ */ new WeakSet();
55
+ nextId = 0;
56
+ disposing = false;
57
+ constructor(ctx) {
58
+ super(ctx, "terminals");
59
+ ctx.effect(() => () => this.disposeAll(), "pty teardown");
60
+ }
61
+ /**
62
+ * Register one backend type for this effect scope.
63
+ * @param backend - provider with a non-empty unique type.
64
+ * @returns disposer that removes exactly this contribution.
65
+ */
66
+ registerBackend(backend) {
67
+ if (backend.type.length === 0) throw new Error("pty backend type must be non-empty");
68
+ if (this.backends.has(backend.type)) throw new TerminalError(`a PTY backend named "${backend.type}" is already registered`, "DUPLICATE_BACKEND");
69
+ const dispose = this.ctx.effect(() => {
70
+ this.backends.set(backend.type, backend);
71
+ return () => {
72
+ if (this.backends.get(backend.type) === backend) this.backends.delete(backend.type);
73
+ };
74
+ }, "pty.registerBackend()");
75
+ return () => void dispose();
76
+ }
77
+ /**
78
+ * List registered backend types in registration order.
79
+ * @returns fresh backend type names.
80
+ */
81
+ listBackends() {
82
+ return [...this.backends.keys()];
83
+ }
84
+ /**
85
+ * Create and publish one owner-scoped session after backend setup succeeds.
86
+ * @param owner - exact registered Agent that owns access and cleanup.
87
+ * @param request - backend type plus optional owner-local name and cwd.
88
+ * @param signal - cancellation of unpublished setup.
89
+ * @returns published identity, metadata, status, and MOTD.
90
+ */
91
+ async spawn(owner, request, signal) {
92
+ this.assertActive();
93
+ signal?.throwIfAborted();
94
+ this.ensureOwnerCleanup(owner);
95
+ const backend = this.backends.get(request.type);
96
+ if (backend === void 0) throw new TerminalError(`no PTY backend registered for "${request.type}"`, "NO_BACKEND");
97
+ if (request.name !== void 0 && request.name.length === 0) throw new Error("PTY session name must be non-empty");
98
+ const releaseName = this.reserveName(owner, request.name);
99
+ const spawnReservation = this.reserveSpawn(owner);
100
+ const backendSignal = signal === void 0 ? spawnReservation.signal : AbortSignal.any([signal, spawnReservation.signal]);
101
+ const sessionId = TerminalSessionId(`pty-${++this.nextId}`);
102
+ let session;
103
+ let cleanupFailure;
104
+ try {
105
+ session = await backend.spawn({
106
+ sessionId,
107
+ owner,
108
+ type: request.type,
109
+ ...request.name !== void 0 ? { name: request.name } : {},
110
+ ...request.cwd !== void 0 ? { cwd: request.cwd } : {},
111
+ signal: backendSignal
112
+ });
113
+ signal?.throwIfAborted();
114
+ if (this.disposing) throw new TerminalError("PTY service is disposing", "SERVICE_DISPOSING");
115
+ if (!this.isLiveOwner(owner)) throw new TerminalError("PTY owner is no longer live", "OWNER_NOT_LIVE");
116
+ const record = {
117
+ id: sessionId,
118
+ owner,
119
+ name: request.name,
120
+ type: request.type,
121
+ session,
122
+ active: void 0,
123
+ closing: void 0
124
+ };
125
+ this.sessions.set(sessionId, record);
126
+ return this.snapshot(record, session.motd);
127
+ } catch (error) {
128
+ if (error instanceof TerminalBackendCleanupError) cleanupFailure = { error: error.cleanupError };
129
+ let rollbackFailure;
130
+ if (session !== void 0 && !this.sessions.has(sessionId)) try {
131
+ await session.close("PTY spawn rolled back");
132
+ } catch (closeError) {
133
+ rollbackFailure = { error: closeError };
134
+ cleanupFailure = rollbackFailure;
135
+ }
136
+ let failure = error;
137
+ try {
138
+ signal?.throwIfAborted();
139
+ spawnReservation.signal.throwIfAborted();
140
+ } catch (cancellation) {
141
+ failure = cancellation;
142
+ }
143
+ if (rollbackFailure !== void 0 && signal?.aborted !== true) throw new AggregateError([failure, rollbackFailure.error], "PTY spawn and rollback both failed");
144
+ throw failure;
145
+ } finally {
146
+ spawnReservation.release(cleanupFailure);
147
+ releaseName();
148
+ }
149
+ }
150
+ /**
151
+ * Test whether an exact owner has a published session or unpublished spawn.
152
+ * @param owner - exact live owner to inspect.
153
+ * @returns true across the entire spawn-to-close interval, with no publication gap.
154
+ */
155
+ hasOwnerActivity(owner) {
156
+ return (this.pendingSpawns.get(owner)?.size ?? 0) > 0 || [...this.sessions.values()].some((record) => record.owner === owner);
157
+ }
158
+ /**
159
+ * Start one exclusive interactive send.
160
+ * @param owner - exact session owner.
161
+ * @param id - target PTY identity.
162
+ * @param request - explicit text, submit behavior, and cancellation.
163
+ * @returns live operation handle for foreground await or task registration.
164
+ */
165
+ startSend(owner, id, request) {
166
+ const record = this.expectOwned(owner, id);
167
+ if (record.closing !== void 0) throw new Error(`PTY session ${id} is closing`);
168
+ if (record.active !== void 0) throw new TerminalError(`PTY session ${id} already has an active send`, "SEND_ACTIVE");
169
+ const operation = record.session.startSend(request);
170
+ record.active = operation;
171
+ operation.done.then(() => {
172
+ record.active = void 0;
173
+ }, () => {
174
+ record.active = void 0;
175
+ });
176
+ return operation;
177
+ }
178
+ /**
179
+ * Read one bounded scrollback page from an owned session.
180
+ * @param owner - exact session owner.
181
+ * @param id - target PTY identity.
182
+ * @param request - optional newest-relative offset and line count.
183
+ * @returns bounded retained text and pagination metadata.
184
+ */
185
+ read(owner, id, request = {}) {
186
+ return this.expectOwned(owner, id).session.read(request);
187
+ }
188
+ /**
189
+ * Deliver an allowed signal through an owned backend session.
190
+ * @param owner - exact session owner.
191
+ * @param id - target PTY identity.
192
+ * @param signal - allowed POSIX signal name.
193
+ * @returns delivered foreground process-group identity.
194
+ */
195
+ signal(owner, id, signal) {
196
+ return this.expectOwned(owner, id).session.signal(signal);
197
+ }
198
+ /**
199
+ * Close one owned session and remove it only after quiescent backend cleanup.
200
+ * @param owner - exact session owner.
201
+ * @param id - target PTY identity.
202
+ * @param reason - diagnostic cleanup reason.
203
+ * @returns true for a newly closed session, false when the same close is already in flight.
204
+ */
205
+ async kill(owner, id, reason = "model request") {
206
+ const record = this.expectOwned(owner, id);
207
+ if (record.closing !== void 0) {
208
+ await record.closing;
209
+ return false;
210
+ }
211
+ const closing = record.session.close(reason);
212
+ record.closing = closing;
213
+ try {
214
+ await closing;
215
+ this.sessions.delete(id);
216
+ return true;
217
+ } catch (error) {
218
+ record.closing = void 0;
219
+ throw error;
220
+ }
221
+ }
222
+ /**
223
+ * List fresh snapshots for exactly one owner.
224
+ * @param owner - exact owner whose sessions are visible.
225
+ * @returns owner-visible snapshots in publication order.
226
+ */
227
+ list(owner) {
228
+ return [...this.sessions.values()].filter((record) => record.owner === owner).map((record) => this.snapshot(record));
229
+ }
230
+ assertActive() {
231
+ if (this.disposing) throw new TerminalError("PTY service is disposing", "SERVICE_DISPOSING");
232
+ }
233
+ isLiveOwner(owner) {
234
+ return !this.disposedOwners.has(owner) && this.ctx.get("agents")?.get(owner.id) === owner;
235
+ }
236
+ ensureOwnerCleanup(owner) {
237
+ if (!this.isLiveOwner(owner)) throw new TerminalError(`agent "${owner.id}" is not the registered PTY owner`, "OWNER_NOT_LIVE");
238
+ if (this.ownerCleanups.has(owner)) return;
239
+ const detach = owner.ctx.effect(() => async () => {
240
+ this.disposedOwners.add(owner);
241
+ this.ownerCleanups.delete(owner);
242
+ await this.disposeOwned(owner);
243
+ }, "pty.ownerCleanup()");
244
+ this.ownerCleanups.set(owner, detach);
245
+ }
246
+ reserveName(owner, name) {
247
+ if (name === void 0) return () => {};
248
+ if ([...this.sessions.values()].some((record) => record.owner === owner && record.name === name)) throw new TerminalError(`PTY session name "${name}" already exists for this owner`, "DUPLICATE_NAME");
249
+ const reserved = this.reservedNames.get(owner) ?? /* @__PURE__ */ new Set();
250
+ if (reserved.has(name)) throw new TerminalError(`PTY session name "${name}" is already being created`, "DUPLICATE_NAME");
251
+ reserved.add(name);
252
+ this.reservedNames.set(owner, reserved);
253
+ return () => {
254
+ reserved.delete(name);
255
+ if (reserved.size === 0) this.reservedNames.delete(owner);
256
+ };
257
+ }
258
+ reserveSpawn(owner) {
259
+ const controller = new AbortController();
260
+ const settlement = Promise.withResolvers();
261
+ const pending = {
262
+ owner,
263
+ controller,
264
+ settled: settlement.promise,
265
+ cleanupFailure: void 0
266
+ };
267
+ const owned = this.pendingSpawns.get(owner) ?? /* @__PURE__ */ new Set();
268
+ owned.add(pending);
269
+ this.pendingSpawns.set(owner, owned);
270
+ return {
271
+ signal: controller.signal,
272
+ release: (cleanupFailure) => {
273
+ pending.cleanupFailure = cleanupFailure;
274
+ if (cleanupFailure === void 0) this.removePendingSpawn(pending);
275
+ settlement.resolve();
276
+ }
277
+ };
278
+ }
279
+ removePendingSpawn(pending) {
280
+ const owned = this.pendingSpawns.get(pending.owner);
281
+ if (owned === void 0) return;
282
+ owned.delete(pending);
283
+ if (owned.size === 0) this.pendingSpawns.delete(pending.owner);
284
+ }
285
+ async abortPendingSpawns(owner, reason) {
286
+ const pending = owner === void 0 ? [...this.pendingSpawns.values()].flatMap((owned) => [...owned]) : [...this.pendingSpawns.get(owner) ?? []];
287
+ for (const spawn of pending) spawn.controller.abort(reason);
288
+ await Promise.all(pending.map((spawn) => spawn.settled));
289
+ const failures = pending.flatMap((spawn) => spawn.cleanupFailure === void 0 ? [] : [spawn.cleanupFailure.error]);
290
+ for (const spawn of pending) this.removePendingSpawn(spawn);
291
+ if (failures.length > 0) throw new AggregateError(failures, "failed to roll back unpublished PTY setup");
292
+ }
293
+ expectOwned(owner, id) {
294
+ const record = this.sessions.get(id);
295
+ if (record === void 0) throw new TerminalError(`unknown PTY session ${id}`, "NO_SESSION");
296
+ if (record.owner !== owner) throw new TerminalError(`PTY session ${id} belongs to another agent`, "FOREIGN_SESSION");
297
+ return record;
298
+ }
299
+ snapshot(record, motd) {
300
+ return {
301
+ sessionId: record.id,
302
+ ...record.name !== void 0 ? { name: record.name } : {},
303
+ type: record.type,
304
+ ...record.session.pid !== void 0 ? { pid: record.session.pid } : {},
305
+ status: record.session.status(),
306
+ ...motd !== void 0 ? { motd } : {}
307
+ };
308
+ }
309
+ async abortAndClose(owner, abortReason, closeReason) {
310
+ const failures = [];
311
+ try {
312
+ await this.abortPendingSpawns(owner, abortReason);
313
+ } catch (error) {
314
+ failures.push(error);
315
+ }
316
+ const records = [...this.sessions.values()].filter((record) => owner === void 0 || record.owner === owner);
317
+ try {
318
+ await this.closeRecords(records, closeReason);
319
+ } catch (error) {
320
+ failures.push(error);
321
+ }
322
+ if (failures.length > 0) throw new AggregateError(failures, "failed to clean up PTY lifecycle");
323
+ }
324
+ async disposeOwned(owner) {
325
+ try {
326
+ await this.abortAndClose(owner, new TerminalError("PTY owner is no longer live", "OWNER_NOT_LIVE"), "PTY owner disposed");
327
+ } finally {
328
+ this.reservedNames.delete(owner);
329
+ }
330
+ }
331
+ async disposeAll() {
332
+ this.disposing = true;
333
+ try {
334
+ await this.abortAndClose(void 0, new TerminalError("PTY service is disposing", "SERVICE_DISPOSING"), "PTY service disposed");
335
+ } finally {
336
+ this.backends.clear();
337
+ this.reservedNames.clear();
338
+ this.pendingSpawns.clear();
339
+ const cleanups = [...this.ownerCleanups.values()];
340
+ this.ownerCleanups.clear();
341
+ await Promise.all(cleanups.map((cleanup) => Promise.resolve(cleanup())));
342
+ }
343
+ }
344
+ async closeRecords(records, reason) {
345
+ const failures = (await Promise.allSettled(records.map(async (record) => {
346
+ const closing = record.closing ?? record.session.close(reason);
347
+ record.closing = closing;
348
+ try {
349
+ await closing;
350
+ this.sessions.delete(record.id);
351
+ } catch (error) {
352
+ if (record.closing === closing) record.closing = void 0;
353
+ throw error;
354
+ }
355
+ }))).filter((result) => result.status === "rejected").map((result) => result.reason);
356
+ if (failures.length > 0) throw new AggregateError(failures, `failed to close ${failures.length} PTY session(s)`);
357
+ }
358
+ };
359
+ //#endregion
360
+ export { TerminalBackendCleanupError, TerminalError, TerminalSessionId, TerminalSessionService, TerminalSessionService as default };
@@ -0,0 +1,23 @@
1
+ //#region lib/types/invariant.js
2
+ /**
3
+ * Package-owned invariant companion for `@hasna-internal/kai-terminal`.
4
+ * @module @hasna-internal/kai-terminal/invariant
5
+ */
6
+ const PACKAGE_NAME = "@hasna-internal/kai-terminal";
7
+ /** Cordis companion plugin name. */
8
+ const name = "terminal-invariant";
9
+ /** Service required before the companion can reserve package ownership. */
10
+ const inject = ["invariants"];
11
+ /**
12
+ * No runtime invariant: backend and owner-scoped session registries are private mutable state,
13
+ * and the service exposes neither an independent lifecycle stream nor an unscoped snapshot.
14
+ */
15
+ const install = () => {};
16
+ /**
17
+ * Register this package's invariant companion.
18
+ * @param ctx - Cordis context carrying the invariant service.
19
+ * @returns the installed registration's disposer after setup succeeds.
20
+ */
21
+ const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
22
+ //#endregion
23
+ export { apply, inject, name };
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Owner-scoped persistent PTY registry. Backends own terminal mechanics while
3
+ * this service owns ids, publication, authorization, and awaited cleanup.
4
+ * @module @hasna-internal/kai-terminal
5
+ */
6
+ import { Context, Service } from '@deepseek-ai/cordis';
7
+ import type { Agent } from '@hasna-internal/kai-agent';
8
+ import type { TerminalBackend, TerminalReadRequest, TerminalReadResult, TerminalSendOperation, TerminalSendRequest, TerminalSessionIdValue, TerminalSessionSnapshot, TerminalSignal, TerminalSignalResult, TerminalSpawnRequest, TerminalSpawnResult } from './types.ts';
9
+ export type { TerminalBackend, TerminalBackendSession, TerminalBackendSpawnSpec, TerminalReadRequest, TerminalReadResult, TerminalSendOperation, TerminalSendRead, TerminalSendRequest, TerminalSendResult, TerminalSessionSnapshot, TerminalSessionStatus, TerminalSignal, TerminalSignalResult, TerminalSpawnRequest, TerminalSpawnResult, TerminalWaitReason, } from './types.ts';
10
+ export { TerminalBackendCleanupError } from './types.ts';
11
+ /** Opaque identity minted by {@link TerminalSessionService} for one live PTY session. */
12
+ export type TerminalSessionId = TerminalSessionIdValue;
13
+ declare module '@deepseek-ai/cordis' {
14
+ interface Context {
15
+ terminals: TerminalSessionService;
16
+ }
17
+ }
18
+ /** Machine-routable PTY service failures. */
19
+ export type TerminalErrorCode = 'DUPLICATE_BACKEND' | 'DUPLICATE_NAME' | 'FOREIGN_SESSION' | 'NO_BACKEND' | 'NO_SESSION' | 'OWNER_NOT_LIVE' | 'SEND_ACTIVE' | 'SERVICE_DISPOSING';
20
+ /** Error carrying a stable {@link TerminalErrorCode}. */
21
+ export declare class TerminalError extends Error {
22
+ readonly code: TerminalErrorCode;
23
+ constructor(message: string, code: TerminalErrorCode);
24
+ }
25
+ /**
26
+ * Brand one registry-minted string as a {@link TerminalSessionId}.
27
+ * @param value - raw registry-issued id.
28
+ * @returns Same string with the PTY session brand.
29
+ */
30
+ export declare function TerminalSessionId(value: string): TerminalSessionId;
31
+ /** In-process registry for replaceable PTY backends and exact-Agent sessions. */
32
+ export declare class TerminalSessionService extends Service {
33
+ private readonly backends;
34
+ private readonly sessions;
35
+ private readonly reservedNames;
36
+ private readonly pendingSpawns;
37
+ private readonly ownerCleanups;
38
+ private readonly disposedOwners;
39
+ private nextId;
40
+ private disposing;
41
+ constructor(ctx: Context);
42
+ /**
43
+ * Register one backend type for this effect scope.
44
+ * @param backend - provider with a non-empty unique type.
45
+ * @returns disposer that removes exactly this contribution.
46
+ */
47
+ registerBackend(backend: TerminalBackend): () => void;
48
+ /**
49
+ * List registered backend types in registration order.
50
+ * @returns fresh backend type names.
51
+ */
52
+ listBackends(): string[];
53
+ /**
54
+ * Create and publish one owner-scoped session after backend setup succeeds.
55
+ * @param owner - exact registered Agent that owns access and cleanup.
56
+ * @param request - backend type plus optional owner-local name and cwd.
57
+ * @param signal - cancellation of unpublished setup.
58
+ * @returns published identity, metadata, status, and MOTD.
59
+ */
60
+ spawn(owner: Agent, request: TerminalSpawnRequest, signal?: AbortSignal): Promise<TerminalSpawnResult>;
61
+ /**
62
+ * Test whether an exact owner has a published session or unpublished spawn.
63
+ * @param owner - exact live owner to inspect.
64
+ * @returns true across the entire spawn-to-close interval, with no publication gap.
65
+ */
66
+ hasOwnerActivity(owner: Agent): boolean;
67
+ /**
68
+ * Start one exclusive interactive send.
69
+ * @param owner - exact session owner.
70
+ * @param id - target PTY identity.
71
+ * @param request - explicit text, submit behavior, and cancellation.
72
+ * @returns live operation handle for foreground await or task registration.
73
+ */
74
+ startSend(owner: Agent, id: TerminalSessionId, request: TerminalSendRequest): TerminalSendOperation;
75
+ /**
76
+ * Read one bounded scrollback page from an owned session.
77
+ * @param owner - exact session owner.
78
+ * @param id - target PTY identity.
79
+ * @param request - optional newest-relative offset and line count.
80
+ * @returns bounded retained text and pagination metadata.
81
+ */
82
+ read(owner: Agent, id: TerminalSessionId, request?: TerminalReadRequest): TerminalReadResult;
83
+ /**
84
+ * Deliver an allowed signal through an owned backend session.
85
+ * @param owner - exact session owner.
86
+ * @param id - target PTY identity.
87
+ * @param signal - allowed POSIX signal name.
88
+ * @returns delivered foreground process-group identity.
89
+ */
90
+ signal(owner: Agent, id: TerminalSessionId, signal: TerminalSignal): Promise<TerminalSignalResult>;
91
+ /**
92
+ * Close one owned session and remove it only after quiescent backend cleanup.
93
+ * @param owner - exact session owner.
94
+ * @param id - target PTY identity.
95
+ * @param reason - diagnostic cleanup reason.
96
+ * @returns true for a newly closed session, false when the same close is already in flight.
97
+ */
98
+ kill(owner: Agent, id: TerminalSessionId, reason?: string): Promise<boolean>;
99
+ /**
100
+ * List fresh snapshots for exactly one owner.
101
+ * @param owner - exact owner whose sessions are visible.
102
+ * @returns owner-visible snapshots in publication order.
103
+ */
104
+ list(owner: Agent): TerminalSessionSnapshot[];
105
+ private assertActive;
106
+ private isLiveOwner;
107
+ private ensureOwnerCleanup;
108
+ private reserveName;
109
+ private reserveSpawn;
110
+ private removePendingSpawn;
111
+ private abortPendingSpawns;
112
+ private expectOwned;
113
+ private snapshot;
114
+ private abortAndClose;
115
+ private disposeOwned;
116
+ private disposeAll;
117
+ private closeRecords;
118
+ }
119
+ export default TerminalSessionService;
120
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Package-owned invariant companion for `@hasna-internal/kai-terminal`.
3
+ * @module @hasna-internal/kai-terminal/invariant
4
+ */
5
+ import type { Context } from '@deepseek-ai/cordis';
6
+ /** Cordis companion plugin name. */
7
+ export declare const name = "terminal-invariant";
8
+ /** Service required before the companion can reserve package ownership. */
9
+ export declare const inject: string[];
10
+ /**
11
+ * Register this package's invariant companion.
12
+ * @param ctx - Cordis context carrying the invariant service.
13
+ * @returns the installed registration's disposer after setup succeeds.
14
+ */
15
+ export declare const apply: (ctx: Context) => Promise<() => void>;
16
+ //# sourceMappingURL=invariant.d.ts.map
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Types shared by PTY backends, the owner-scoped registry, and tool consumers.
3
+ * Runtime service code lives in `./index.ts`.
4
+ * @module @hasna-internal/kai-terminal/types
5
+ */
6
+ import type { Branded } from '@hasna-internal/kai-brand';
7
+ import type { Agent } from '@hasna-internal/kai-agent';
8
+ /** Internal exported basis for the public `TerminalSessionId` type/value pair. */
9
+ export type TerminalSessionIdValue = Branded<'TerminalSessionId'>;
10
+ /**
11
+ * Backend-reported failure to clean partial resources after unpublished setup failed.
12
+ * @param spawnError - original setup or cancellation failure.
13
+ * @param cleanupError - failure that may leave backend-owned resources alive.
14
+ */
15
+ export declare class TerminalBackendCleanupError extends AggregateError {
16
+ readonly spawnError: unknown;
17
+ readonly cleanupError: unknown;
18
+ constructor(spawnError: unknown, cleanupError: unknown);
19
+ }
20
+ /** Why one interactive send returned control to its caller. */
21
+ export type TerminalWaitReason = 'stdin_read' | 'inferred_idle' | 'timeout' | 'session_exit';
22
+ /**
23
+ * Signals the model-facing PTY surface permits for foreground process groups.
24
+ * Kept member-identical to `SubprocessTerminalSignal` in
25
+ * `@hasna-internal/kai-subprocess` without a cross-seam dependency; change both together.
26
+ */
27
+ export type TerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP';
28
+ /** Top-level PTY process status, independent of a send's wait reason. */
29
+ export type TerminalSessionStatus = {
30
+ kind: 'running';
31
+ } | {
32
+ kind: 'exited';
33
+ exitCode: number | null;
34
+ signal: NodeJS.Signals | null;
35
+ };
36
+ /** Request to create one owner-scoped PTY session. */
37
+ export interface TerminalSpawnRequest {
38
+ /** Registered backend type. */
39
+ type: string;
40
+ /** Optional owner-local display name. */
41
+ name?: string;
42
+ /** Optional initial working directory interpreted by the backend. */
43
+ cwd?: string;
44
+ }
45
+ /** Fully identified request handed from the registry to a backend. */
46
+ export interface TerminalBackendSpawnSpec extends TerminalSpawnRequest {
47
+ /** Registry-minted session identity. */
48
+ sessionId: TerminalSessionIdValue;
49
+ /** Exact live owner for authority-aware backend setup. */
50
+ owner: Agent;
51
+ /** Cancellation of unpublished backend setup. */
52
+ signal?: AbortSignal;
53
+ }
54
+ /** Input for one line-oriented terminal interaction. */
55
+ export interface TerminalSendRequest {
56
+ /** UTF-8 text to write. */
57
+ text: string;
58
+ /** Whether to write the backend's Enter sequence after {@link text}. */
59
+ submit: boolean;
60
+ /** Cancellation for the wait; backends also interrupt the foreground command. */
61
+ signal?: AbortSignal;
62
+ }
63
+ /** Incremental output consumed from one live send operation. */
64
+ export interface TerminalSendRead {
65
+ /** Output produced since the previous operation read. */
66
+ delta: string;
67
+ /** Whether unread operation output was dropped by the backend's bound. */
68
+ truncated: boolean;
69
+ }
70
+ /** Settled result for one foreground or background send. */
71
+ export interface TerminalSendResult {
72
+ /** Bounded rendered terminal delta remaining at settlement. */
73
+ viewport: string;
74
+ /** Why the wait returned; this does not imply arbitrary child-process exit. */
75
+ waitReason: TerminalWaitReason;
76
+ /** Top-level session status observed at settlement. */
77
+ sessionStatus: TerminalSessionStatus;
78
+ /** Whether output was dropped from the operation or retained scrollback. */
79
+ truncated: boolean;
80
+ }
81
+ /** Live backend-owned send; exactly one may be active per PTY session. */
82
+ export interface TerminalSendOperation {
83
+ /** Resolves after readiness, timeout, cancellation, or top-level process exit. */
84
+ done: Promise<TerminalSendResult>;
85
+ /** Consume output produced since the prior call. */
86
+ readOutput(): TerminalSendRead;
87
+ /** Request `SIGINT`; returns false after the operation settled. */
88
+ cancel(): boolean;
89
+ }
90
+ /** Request for one backward scrollback page. */
91
+ export interface TerminalReadRequest {
92
+ /** Offset from the newest retained line; defaults are backend-owned. */
93
+ offset?: number;
94
+ /** Requested line count; backend limits still apply. */
95
+ count?: number;
96
+ }
97
+ /** Bounded scrollback page. */
98
+ export interface TerminalReadResult {
99
+ /** Retained text in chronological order. */
100
+ text: string;
101
+ /** Number of lines currently retained. */
102
+ totalLines: number;
103
+ /** Inclusive newest-relative offset of the first returned line. */
104
+ lineBegin: number;
105
+ /** Exclusive newest-relative offset after the returned page. */
106
+ lineEnd: number;
107
+ /** Whether older retained output or the requested result exceeded a bound. */
108
+ truncated: boolean;
109
+ }
110
+ /** Result of delivering a signal to a verified foreground process group. */
111
+ export interface TerminalSignalResult {
112
+ /** True only after the backend delivered the signal. */
113
+ delivered: true;
114
+ /** Process group that received the signal. */
115
+ targetPgid: number;
116
+ }
117
+ /** Owner-visible summary of one published PTY session. */
118
+ export interface TerminalSessionSnapshot {
119
+ /** Registry-minted identity used by every operation. */
120
+ sessionId: TerminalSessionIdValue;
121
+ /** Optional owner-local display name. */
122
+ name?: string;
123
+ /** Backend type that created the session. */
124
+ type: string;
125
+ /** Top-level process id when the backend has one. */
126
+ pid?: number;
127
+ /** Current top-level process status. */
128
+ status: TerminalSessionStatus;
129
+ }
130
+ /** Backend-owned live session retained by {@link TerminalSessionService}. */
131
+ export interface TerminalBackendSession {
132
+ /** Initial bounded terminal output returned from `terminal_open`. */
133
+ readonly motd: string;
134
+ /** Top-level process id when one exists. */
135
+ readonly pid?: number;
136
+ /** Start one exclusive send operation. */
137
+ startSend(request: TerminalSendRequest): TerminalSendOperation;
138
+ /** Read one bounded page from retained scrollback. */
139
+ read(request: TerminalReadRequest): TerminalReadResult;
140
+ /** Signal the verified foreground process group. */
141
+ signal(signal: TerminalSignal): Promise<TerminalSignalResult>;
142
+ /** Observe top-level process status. */
143
+ status(): TerminalSessionStatus;
144
+ /** Idempotently close the captured owned process tree and await quiescence. */
145
+ close(reason: string): Promise<void>;
146
+ }
147
+ /** Replaceable provider for one PTY session type. */
148
+ export interface TerminalBackend {
149
+ /** Stable type selected by {@link TerminalSpawnRequest.type}. */
150
+ readonly type: string;
151
+ /** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses {@link TerminalBackendCleanupError}. */
152
+ spawn(spec: TerminalBackendSpawnSpec): Promise<TerminalBackendSession>;
153
+ }
154
+ /** Successful publication returned by {@link TerminalSessionService.spawn}. */
155
+ export interface TerminalSpawnResult extends TerminalSessionSnapshot {
156
+ /** Initial bounded output captured before publication. */
157
+ motd: string;
158
+ }
159
+ //# sourceMappingURL=types.d.ts.map
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@hasna-internal/kai-terminal",
3
+ "description": "Persistent PTY session seam for the DeepSeek Harness — owner-scoped ids, backend registry, interactive sends, reads, signals, and awaited cleanup",
4
+ "version": "0.1.1-rc.2",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
11
+ "directory": "packages/terminal/terminal"
12
+ },
13
+ "type": "module",
14
+ "main": "lib/index.js",
15
+ "types": "lib/types/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./lib/types/index.d.ts",
19
+ "default": "./lib/index.js"
20
+ },
21
+ "./invariant": {
22
+ "types": "./lib/types/invariant.d.ts",
23
+ "default": "./lib/invariant.js"
24
+ },
25
+ "./src/*": "./src/*",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": [
29
+ "lib/index.js",
30
+ "lib/invariant.js",
31
+ "lib/types/**/*.d.ts"
32
+ ],
33
+ "license": "MIT",
34
+ "peerDependencies": {
35
+ "@hasna-internal/kai-agent": "^0.1.1-rc.2",
36
+ "@hasna-internal/kai-brand": "^0.1.1-rc.2",
37
+ "@deepseek-ai/cordis": "^4.0.1",
38
+ "@hasna-internal/kai-invariants": "^0.1.1-rc.2"
39
+ },
40
+ "devDependencies": {
41
+ "@hasna-internal/kai-agent": "^0.1.1-rc.2",
42
+ "@hasna-internal/kai-brand": "^0.1.1-rc.2",
43
+ "@deepseek-ai/cordis": "^4.0.1",
44
+ "@hasna-internal/kai-session": "^0.1.1-rc.2",
45
+ "@hasna-internal/kai-invariants": "^0.1.1-rc.2"
46
+ }
47
+ }