dsh-bash-terminal-ts 0.2.6 → 0.5.0

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/src/terminal.ts CHANGED
@@ -1,23 +1,36 @@
1
- // dsh-bash-terminal-ts: interactive terminal tool over the official PTY seam.
1
+ // dsh-bash-terminal: interactive terminal tool over the official PTY seam.
2
2
  // ctx.subprocess.spawnTerminal (node-pty under the hood) allocates a real
3
3
  // terminal; this module owns model-facing sessions: open / send / read /
4
4
  // signal (Ctrl+C etc.) / close. The backend follows the user's default
5
5
  // terminal setting, exactly like the shell tool.
6
+ //
7
+ // Windows note: the official seam's process inspector is POSIX-only
8
+ // (createProcessInspector throws on win32), so on Windows the PTY is
9
+ // allocated directly through node-pty. Session readiness here is
10
+ // output-quiet based (waitSettled), which needs no process inspection.
6
11
 
7
12
  import { randomUUID } from "node:crypto";
8
13
  import { isAbsolute, resolve } from "node:path";
14
+ import { PassThrough } from "node:stream";
9
15
  import { TOOL_ABORTED, defineTool, HarnessError } from "./dsh.js";
16
+ // Single source of truth for backend env (msys2 MSYSTEM injection, WSL WSLENV).
17
+ // The cycle with ./index.js is safe: buildEnv is a hoisted function declaration
18
+ // and is only called at tool-execution time, never during module evaluation.
10
19
  import { buildEnv } from "./index.js";
11
20
  import type {
12
21
  BashTerminalContext,
22
+ JobsRegistry,
13
23
  ResolvedPaths,
14
- TerminalHandle
24
+ TerminalHandle,
25
+ TerminalSpawnSpec,
26
+ ToolRunContext
15
27
  } from "./dsh-types.js";
16
28
 
17
29
  const MAX_BUFFER_BYTES = 1024 * 1024;
18
- const READ_SETTLE_MS = 800;
19
30
  const DEFAULT_ROWS = 30;
20
31
  const DEFAULT_COLS = 110;
32
+ const DEFAULT_IDLE_MS = 10 * 60 * 1000;
33
+ const MAX_SESSIONS = 8;
21
34
  const TERMINAL_SIGNALS = ["SIGINT", "SIGTERM", "SIGKILL", "SIGTSTP", "SIGHUP"] as const;
22
35
 
23
36
  export type TerminalSignal = (typeof TERMINAL_SIGNALS)[number];
@@ -29,12 +42,34 @@ function delay(ms: number): Promise<void> {
29
42
  interface TerminalBufferRead {
30
43
  delta: string;
31
44
  nextOffset: number;
45
+ truncated: boolean;
32
46
  }
33
47
 
34
48
  interface TerminalBuffer {
35
49
  append(chunk: string): void;
36
50
  readFrom(offset: number): TerminalBufferRead;
37
51
  snapshot(): string;
52
+ wasTruncated(): boolean;
53
+ lastAppendAt(): number;
54
+ }
55
+
56
+ /**
57
+ * Wait until the terminal output goes quiet (no new bytes for quietMs) or the
58
+ * cap elapses. Reads after interactive input then return the complete reply
59
+ * instead of a fixed-delay slice.
60
+ */
61
+ async function waitSettled(buffer: TerminalBuffer, quietMs = 300, timeoutMs = 5000): Promise<void> {
62
+ const start = Date.now();
63
+ let last = buffer.lastAppendAt();
64
+ while (Date.now() - start < timeoutMs) {
65
+ await delay(80);
66
+ const now = buffer.lastAppendAt();
67
+ if (now > last) {
68
+ last = now;
69
+ continue;
70
+ }
71
+ if (Date.now() - last >= quietMs) return;
72
+ }
38
73
  }
39
74
 
40
75
  /** Interactive-shell argv (no -c: the terminal itself is the session). */
@@ -46,10 +81,21 @@ export function terminalArgv(
46
81
  switch (shell) {
47
82
  case "powershell": return [paths.pwsh, "-NoLogo", "-NoProfile"];
48
83
  case "gitbash": return [paths.gitbash, "-i"];
49
- case "msys2": return [paths.msys2, "-l"];
84
+ case "msys2":
85
+ // -l, NOT -lc: this argv carries no command (the PTY itself is the
86
+ // session), and `bash -lc` with no operand dies immediately with
87
+ // "-c: option requires an argument" (measured: exit 2). A login shell is
88
+ // also what sources /etc/profile, putting /usr/bin and /mingw64/bin on
89
+ // PATH for the interactive session.
90
+ return [paths.msys2, "-l"];
50
91
  case "wsl": {
51
- const distroArg = distro !== undefined && distro.trim().length > 0 ? ["-d", distro.trim()] : [];
52
- return [paths.wsl, ...distroArg, "-e", "bash", "-i"];
92
+ // Verified under ConPTY: 'wsl -e bash -i' on the DEFAULT distro fails with
93
+ // a WSL service RPC error (0x8007072c), while 'wsl -- bash -i' works;
94
+ // with an explicit -d distro, '-e bash -i' also works.
95
+ if (distro !== undefined && distro.trim().length > 0) {
96
+ return [paths.wsl, "-d", distro.trim(), "-e", "bash", "-i"];
97
+ }
98
+ return [paths.wsl, "--", "bash", "-i"];
53
99
  }
54
100
  default: throw new Error("invalid shell: " + JSON.stringify(shell));
55
101
  }
@@ -58,19 +104,86 @@ export function terminalArgv(
58
104
  /** In-memory output ring for one session (drop-oldest at the cap). */
59
105
  function createBuffer(): TerminalBuffer {
60
106
  let text = "";
107
+ let truncated = false;
108
+ let lastAppendAt = Date.now();
61
109
  return {
62
110
  append(chunk: string) {
63
111
  text += chunk;
64
- if (text.length > MAX_BUFFER_BYTES) text = text.slice(text.length - MAX_BUFFER_BYTES);
112
+ lastAppendAt = Date.now();
113
+ if (text.length > MAX_BUFFER_BYTES) {
114
+ text = text.slice(text.length - MAX_BUFFER_BYTES);
115
+ truncated = true;
116
+ }
65
117
  },
66
118
  readFrom(offset: number): TerminalBufferRead {
67
119
  const delta = offset >= text.length ? "" : text.slice(offset);
68
- return { delta, nextOffset: text.length };
120
+ return { delta, nextOffset: text.length, truncated };
69
121
  },
70
- snapshot: () => text
122
+ snapshot: () => text,
123
+ wasTruncated: () => truncated,
124
+ lastAppendAt: () => lastAppendAt
71
125
  };
72
126
  }
73
127
 
128
+ /** Minimal node-pty face used by the direct-win32 path (shape-cast at import). */
129
+ interface NodePtyTerm {
130
+ pid: number;
131
+ write(data: string): void;
132
+ kill(signal?: string): void;
133
+ onData(callback: (chunk: string) => void): void;
134
+ onExit(callback: (event: { exitCode: number; signal: unknown }) => void): void;
135
+ }
136
+
137
+ type NodePtySpawn = (
138
+ file: string,
139
+ args: readonly string[],
140
+ options: { name: string; cols: number; rows: number; cwd: string; env: Record<string, string | undefined> }
141
+ ) => NodePtyTerm;
142
+
143
+ /**
144
+ * Direct node-pty handle with the same surface the official seam returns:
145
+ * output (stream), pid, done, write / signalForeground / terminate. Used on
146
+ * win32 where the official spawnTerminal's process inspector is unsupported.
147
+ */
148
+ async function spawnPtyHandle(spec: TerminalSpawnSpec): Promise<TerminalHandle> {
149
+ const { argv, cwd, env, rows, cols } = spec;
150
+ const mod = (await import("node-pty")) as unknown as { default?: { spawn: NodePtySpawn }; spawn: NodePtySpawn };
151
+ const nodePty = mod.default ?? mod;
152
+ const term = nodePty.spawn(argv[0]!, argv.slice(1), {
153
+ name: "dumb",
154
+ cols,
155
+ rows,
156
+ cwd,
157
+ env: { ...env, TERM: "dumb", NO_COLOR: "1" }
158
+ });
159
+ const output = new PassThrough();
160
+ term.onData((chunk) => output.write(Buffer.from(chunk, "utf8")));
161
+ const done = new Promise<{ exitCode: number; signal: string | null }>((resolveDone) => {
162
+ term.onExit(({ exitCode, signal }) => resolveDone({ exitCode, signal: typeof signal === "string" ? signal : null }));
163
+ });
164
+ return {
165
+ pid: term.pid,
166
+ output,
167
+ done,
168
+ write: (data) => term.write(data),
169
+ // node-pty accepts no named signals on Windows: Ctrl+C is the \x03 byte
170
+ // (ConPTY delivers it to the foreground process), other signals degrade
171
+ // to terminating the session.
172
+ signalForeground: (sig) => {
173
+ if (sig === "SIGINT") { term.write("\x03"); return; }
174
+ term.kill();
175
+ },
176
+ terminate: async () => { try { term.kill(); } catch { /* already dead */ } }
177
+ };
178
+ }
179
+
180
+ export interface TerminalSessionInfo {
181
+ sessionId: string;
182
+ shell: string;
183
+ pid: number;
184
+ closed: boolean;
185
+ }
186
+
74
187
  interface TerminalSession {
75
188
  id: string;
76
189
  handle: TerminalHandle;
@@ -78,6 +191,9 @@ interface TerminalSession {
78
191
  shell: string;
79
192
  distro?: string;
80
193
  closed: boolean;
194
+ idleMs: number;
195
+ idleTimer?: NodeJS.Timeout;
196
+ touch: () => void;
81
197
  }
82
198
 
83
199
  export interface TerminalOpenOptions {
@@ -89,6 +205,7 @@ export interface TerminalOpenOptions {
89
205
  cols: number;
90
206
  distro?: string;
91
207
  initial?: string;
208
+ idleMs?: number;
92
209
  }
93
210
 
94
211
  export interface TerminalRegistry {
@@ -98,6 +215,7 @@ export interface TerminalRegistry {
98
215
  read(id: string): TerminalBufferRead;
99
216
  signal(id: string, sig: string): Promise<TerminalBufferRead>;
100
217
  close(id: string): Promise<boolean>;
218
+ list(): TerminalSessionInfo[];
101
219
  }
102
220
 
103
221
  /**
@@ -115,19 +233,20 @@ export function createTerminalRegistry(ctx: BashTerminalContext): TerminalRegist
115
233
  }, "bash-terminal: terminal sessions teardown");
116
234
 
117
235
  async function open(options: TerminalOpenOptions): Promise<TerminalSession> {
118
- const { argv, shell, cwd, env, rows, cols, distro, initial } = options;
119
- const spawnTerminal = ctx.subprocess?.spawnTerminal;
120
- if (spawnTerminal === undefined) {
121
- throw new Error("terminal: ctx.subprocess.spawnTerminal seam unavailable");
236
+ const { argv, shell, cwd, env, rows, cols, distro, initial, idleMs } = options;
237
+ if (sessions.size >= MAX_SESSIONS) {
238
+ throw new Error("terminal: too many open sessions (" + MAX_SESSIONS + "); close one before opening another");
239
+ }
240
+ let handle: TerminalHandle;
241
+ if (process.platform === "win32") {
242
+ handle = await spawnPtyHandle({ argv, cwd, env, rows, cols });
243
+ } else {
244
+ const spawnTerminal = ctx.subprocess?.spawnTerminal;
245
+ if (spawnTerminal === undefined) {
246
+ throw new Error("terminal: ctx.subprocess.spawnTerminal seam unavailable");
247
+ }
248
+ handle = await spawnTerminal({ argv, cwd, env, rows, cols, graceMs: 3000 });
122
249
  }
123
- const handle = await spawnTerminal({
124
- argv,
125
- cwd,
126
- env,
127
- rows,
128
- cols,
129
- graceMs: 3000
130
- });
131
250
  const buffer = createBuffer();
132
251
  handle.output.on("data", (chunk: unknown) => buffer.append(typeof chunk === "string" ? chunk : String(chunk)));
133
252
  const session: TerminalSession = {
@@ -136,12 +255,28 @@ export function createTerminalRegistry(ctx: BashTerminalContext): TerminalRegist
136
255
  buffer,
137
256
  shell,
138
257
  distro,
139
- closed: false
258
+ closed: false,
259
+ idleMs: idleMs ?? DEFAULT_IDLE_MS,
260
+ touch: () => { /* replaced below */ }
140
261
  };
262
+ // Idle guard: any read/send/signal touches the timer; expiry terminates the
263
+ // session so an abandoned PTY never leaks a process tree.
264
+ const touch = (): void => {
265
+ if (session.closed) return;
266
+ clearTimeout(session.idleTimer);
267
+ session.idleTimer = setTimeout(() => {
268
+ if (session.closed) return;
269
+ session.closed = true;
270
+ sessions.delete(session.id);
271
+ void session.handle.terminate().catch(() => { /* already dead */ });
272
+ }, session.idleMs);
273
+ };
274
+ session.touch = touch;
275
+ touch();
141
276
  sessions.set(session.id, session);
142
277
  if (initial !== undefined && initial.length > 0) {
143
278
  await handle.write(initial);
144
- await delay(READ_SETTLE_MS);
279
+ await waitSettled(buffer);
145
280
  }
146
281
  return session;
147
282
  }
@@ -150,55 +285,67 @@ export function createTerminalRegistry(ctx: BashTerminalContext): TerminalRegist
150
285
  const session = sessions.get(id);
151
286
  if (session === undefined) throw new Error("terminal session not found: " + JSON.stringify(id));
152
287
  if (session.closed) throw new Error("terminal session is closed: " + JSON.stringify(id));
288
+ session.touch();
153
289
  return session;
154
290
  }
155
291
 
156
292
  async function send(id: string, input: string): Promise<TerminalBufferRead> {
157
293
  const session = get(id);
158
294
  await session.handle.write(input);
159
- await delay(READ_SETTLE_MS);
295
+ await waitSettled(session.buffer);
160
296
  return read(id);
161
297
  }
162
298
 
163
- function read(id: string): TerminalBufferRead {
164
- const session = get(id);
165
- const { delta, nextOffset } = session.buffer.readFrom(0);
166
- return { delta, nextOffset };
167
- }
168
-
169
299
  async function signal(id: string, sig: string): Promise<TerminalBufferRead> {
170
300
  const session = get(id);
171
301
  if (!(TERMINAL_SIGNALS as readonly string[]).includes(sig)) {
172
302
  throw new Error("invalid terminal signal: " + JSON.stringify(sig));
173
303
  }
174
304
  await session.handle.signalForeground(sig);
175
- await delay(READ_SETTLE_MS);
305
+ await waitSettled(session.buffer);
176
306
  return read(id);
177
307
  }
178
308
 
309
+ function read(id: string): TerminalBufferRead {
310
+ const session = get(id);
311
+ const { delta, nextOffset, truncated } = session.buffer.readFrom(0);
312
+ return { delta, nextOffset, truncated };
313
+ }
314
+
179
315
  async function close(id: string): Promise<boolean> {
180
316
  const session = get(id);
181
317
  session.closed = true;
318
+ clearTimeout(session.idleTimer);
182
319
  sessions.delete(id);
183
320
  await session.handle.terminate();
184
321
  return true;
185
322
  }
186
323
 
187
- return { open, get, send, read, signal, close };
324
+ function list(): TerminalSessionInfo[] {
325
+ return [...sessions.values()].map((session) => ({
326
+ sessionId: session.id,
327
+ shell: session.shell,
328
+ pid: session.handle.pid,
329
+ closed: session.closed
330
+ }));
331
+ }
332
+
333
+ return { open, get, send, read, signal, close, list };
188
334
  }
189
335
 
190
336
  /** The terminal tool's arguments after runtime validation (discriminated by action). */
191
337
  export type ValidatedTerminalArgs =
192
- | { action: "open"; sessionId?: string; command?: string; distro?: string; workdir?: string }
338
+ | { action: "open"; sessionId?: string; command?: string; distro?: string; workdir?: string; idleMs?: number }
193
339
  | { action: "send"; sessionId: string; input: string }
194
340
  | { action: "read"; sessionId: string }
195
341
  | { action: "signal"; sessionId: string; signal: TerminalSignal }
196
- | { action: "close"; sessionId: string };
342
+ | { action: "close"; sessionId: string }
343
+ | { action: "list" };
197
344
 
198
345
  export function validateArgs(args: Record<string, unknown>): asserts args is ValidatedTerminalArgs {
199
346
  const action = args.action;
200
- if (typeof action !== "string" || ["open", "send", "read", "signal", "close"].indexOf(action) === -1) {
201
- throw new Error("invalid action: expected open, send, read, signal, or close");
347
+ if (typeof action !== "string" || ["open", "send", "read", "signal", "close", "list"].indexOf(action) === -1) {
348
+ throw new Error("invalid action: expected open, send, read, signal, close, or list");
202
349
  }
203
350
  if ((action === "send" || action === "read" || action === "signal" || action === "close") && (typeof args.sessionId !== "string" || args.sessionId.length === 0)) {
204
351
  throw new Error("invalid sessionId: required for " + action);
@@ -214,6 +361,7 @@ export function validateArgs(args: Record<string, unknown>): asserts args is Val
214
361
  export interface TerminalOpenResult {
215
362
  kind: "open";
216
363
  sessionId: string;
364
+ jobId?: string;
217
365
  pid: number;
218
366
  shell: string;
219
367
  output: string;
@@ -222,6 +370,7 @@ export interface TerminalOpenResult {
222
370
  export interface TerminalSessionResult {
223
371
  kind: "session";
224
372
  output: string;
373
+ truncated?: boolean;
225
374
  }
226
375
 
227
376
  export interface TerminalClosedResult {
@@ -229,7 +378,12 @@ export interface TerminalClosedResult {
229
378
  sessionId: string;
230
379
  }
231
380
 
232
- export type TerminalToolResult = TerminalOpenResult | TerminalSessionResult | TerminalClosedResult;
381
+ export interface TerminalListResult {
382
+ kind: "list";
383
+ sessions: TerminalSessionInfo[];
384
+ }
385
+
386
+ export type TerminalToolResult = TerminalOpenResult | TerminalSessionResult | TerminalClosedResult | TerminalListResult;
233
387
 
234
388
  export function terminalTool(
235
389
  ctx: BashTerminalContext,
@@ -239,13 +393,13 @@ export function terminalTool(
239
393
  ) {
240
394
  return defineTool<TerminalToolResult>({
241
395
  name: "terminal",
242
- description: "Interactive terminal session over the user's default terminal (Settings -> General -> Default terminal: powershell / gitbash / msys2 / wsl). A real PTY hosts a persistent shell: open a session, send input and read output across turns, deliver signals (Ctrl+C = SIGINT) to the foreground process, and close when done. Backend and env follow the shell tool exactly; the session survives between calls until closed. Use this for interactive programs (REPLs, ssh, databases, TUI tools) or when you need shell state (cwd, variables, aliases) to persist across calls.",
396
+ description: "Interactive terminal session over the user's default terminal (Settings -> General -> Default terminal: powershell / gitbash / msys2 / wsl). A real PTY hosts a persistent shell: open a session, send input and read output across turns, deliver signals (Ctrl+C = SIGINT) to the foreground process, and close when done. Backend and env follow the shell tool exactly; the session survives between calls until closed and is managed as a background job (job_kill / job_output work on it); idle sessions close automatically. Use this for interactive programs (REPLs, ssh, databases, TUI tools) or when you need shell state (cwd, variables, aliases) to persist across calls.",
243
397
  parameters: {
244
398
  action: {
245
399
  type: "string",
246
- enum: ["open", "send", "read", "signal", "close"],
400
+ enum: ["open", "send", "read", "signal", "close", "list"],
247
401
  required: true,
248
- description: "open: create a session and return its id. send: write input and read new output. read: read new output without writing. signal: send a signal to the foreground process (SIGINT for Ctrl+C). close: terminate the session."
402
+ description: "open: create a session and return its id. send: write input and read new output. read: read new output without writing. signal: send a signal to the foreground process (SIGINT for Ctrl+C). close: terminate the session. list: list live sessions."
249
403
  },
250
404
  sessionId: {
251
405
  type: "string",
@@ -271,6 +425,10 @@ export function terminalTool(
271
425
  workdir: {
272
426
  type: "string",
273
427
  description: "Working directory for the session (open only). Defaults to the session workspace."
428
+ },
429
+ idleMs: {
430
+ type: "number",
431
+ description: "Idle timeout in ms (open only): the session closes automatically after this long without send/read/signal. Default 600000 (10 min)."
274
432
  }
275
433
  },
276
434
  output: {
@@ -282,6 +440,7 @@ export function terminalTool(
282
440
  properties: {
283
441
  kind: { type: "string", required: true, const: "open" },
284
442
  sessionId: { type: "string", required: true },
443
+ jobId: { type: "string" },
285
444
  pid: { type: "integer", required: true },
286
445
  shell: { type: "string", required: true },
287
446
  output: { type: "string" }
@@ -292,7 +451,8 @@ export function terminalTool(
292
451
  additionalProperties: false,
293
452
  properties: {
294
453
  kind: { type: "string", required: true, const: "session" },
295
- output: { type: "string", required: true }
454
+ output: { type: "string", required: true },
455
+ truncated: { type: "boolean" }
296
456
  }
297
457
  },
298
458
  {
@@ -302,6 +462,26 @@ export function terminalTool(
302
462
  kind: { type: "string", required: true, const: "closed" },
303
463
  sessionId: { type: "string", required: true }
304
464
  }
465
+ },
466
+ {
467
+ type: "object",
468
+ additionalProperties: false,
469
+ properties: {
470
+ kind: { type: "string", required: true, const: "list" },
471
+ sessions: {
472
+ type: "array",
473
+ items: {
474
+ type: "object",
475
+ additionalProperties: false,
476
+ properties: {
477
+ sessionId: { type: "string", required: true },
478
+ shell: { type: "string", required: true },
479
+ pid: { type: "integer", required: true },
480
+ closed: { type: "boolean" }
481
+ }
482
+ }
483
+ }
484
+ }
305
485
  }
306
486
  ]
307
487
  },
@@ -311,7 +491,11 @@ export function terminalTool(
311
491
  ? "terminal session " + value.sessionId + " (pid " + value.pid + ", " + value.shell + ")" + (value.output ? "\n" + value.output : "")
312
492
  : value.kind === "closed"
313
493
  ? "terminal session " + value.sessionId + " closed"
314
- : value.output
494
+ : value.kind === "list"
495
+ ? (value.sessions.length === 0 ? "no open terminal sessions" : value.sessions.map((s) => s.sessionId + " (" + s.shell + ", pid " + s.pid + ")").join("\n"))
496
+ : value.truncated === true
497
+ ? (value.output.length === 0 ? "[terminal buffer overflowed; oldest output dropped]" : value.output + "\n[terminal buffer overflowed; oldest output dropped]")
498
+ : value.output
315
499
  }]
316
500
  },
317
501
  async execute(args, exec): Promise<TerminalToolResult> {
@@ -333,30 +517,46 @@ export function terminalTool(
333
517
  // buildEnv, not an inline duplicate: the msys2 backend needs its
334
518
  // MSYSTEM=MINGW64 injection here too, or the login shell sources
335
519
  // /etc/profile with the default MSYS environment and /mingw64/bin
336
- // (gcc, make) never joins PATH. DSH's spawnTerminal replaces the child
337
- // environment with exactly this object (childEnv(spec.env)), so the
338
- // inherited WSLENV is passed through explicitly for the WSL allow-list;
339
- // for every other backend buildEnv ignores the third argument.
520
+ // (gcc, make) never joins PATH. It also owns the WSL WSLENV layering,
521
+ // so a terminal session's env follows the shell tool exactly.
522
+ // process.env.WSLENV is passed explicitly: spawnTerminal replaces the
523
+ // child environment wholesale (childEnv(spec.env)), so the ambient
524
+ // allow-list is not otherwise visible and inherited entries such as
525
+ // WT_SESSION / WT_PROFILE_ID would be dropped.
340
526
  const env = buildEnv(shell, ctx.shellEnv.collect(exec), process.env.WSLENV);
341
- const session = await registry.open({ argv, shell, cwd, env, rows: DEFAULT_ROWS, cols: DEFAULT_COLS, distro: args.distro, initial: args.command !== undefined ? args.command + "\r" : undefined });
342
- return { kind: "open", sessionId: session.id, pid: session.handle.pid, shell, output: session.buffer.snapshot() };
527
+ const session = await registry.open({ argv, shell, cwd, env, rows: DEFAULT_ROWS, cols: DEFAULT_COLS, distro: args.distro, initial: args.command !== undefined ? args.command + "\r" : undefined, idleMs: args.idleMs });
528
+ const jobs = ctx.get("jobs") as JobsRegistry | undefined;
529
+ const jobId = jobs === undefined ? undefined : jobs.start({
530
+ kind: "terminal/session",
531
+ label: "terminal " + session.id.slice(0, 8) + " (" + shell + ")",
532
+ ...(exec.agent ? { owner: exec.agent } : {}),
533
+ run: () => ({
534
+ cancel: () => { void session.handle.terminate().catch(() => { /* already dead */ }); },
535
+ done: session.handle.done,
536
+ readOutput: () => registry.read(session.id).delta
537
+ })
538
+ });
539
+ return { kind: "open", sessionId: session.id, ...(jobId !== undefined ? { jobId } : {}), pid: session.handle.pid, shell, output: session.buffer.snapshot() };
343
540
  }
344
541
  case "send": {
345
- const { delta } = await registry.send(args.sessionId, args.input);
346
- return { kind: "session", output: delta };
542
+ const { delta, truncated } = await registry.send(args.sessionId, args.input);
543
+ return { kind: "session", output: delta, ...(truncated ? { truncated } : {}) };
347
544
  }
348
545
  case "read": {
349
- const { delta } = registry.read(args.sessionId);
350
- return { kind: "session", output: delta };
546
+ const { delta, truncated } = registry.read(args.sessionId);
547
+ return { kind: "session", output: delta, ...(truncated ? { truncated } : {}) };
351
548
  }
352
549
  case "signal": {
353
- const { delta } = await registry.signal(args.sessionId, args.signal);
354
- return { kind: "session", output: delta };
550
+ const { delta, truncated } = await registry.signal(args.sessionId, args.signal);
551
+ return { kind: "session", output: delta, ...(truncated ? { truncated } : {}) };
355
552
  }
356
553
  case "close": {
357
554
  await registry.close(args.sessionId);
358
555
  return { kind: "closed", sessionId: args.sessionId };
359
556
  }
557
+ case "list": {
558
+ return { kind: "list", sessions: registry.list() };
559
+ }
360
560
  }
361
561
  },
362
562
  presentCall: (args) => ({