@deepseek-ai/dsh-api-terminal-controller 0.1.6-alpha.1 → 0.1.6-alpha.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/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
- import { createRequire } from "node:module";
2
1
  import z from "@deepseek-ai/schemastery";
3
2
  import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
4
3
  import { SubprocessExecutableNotFoundError } from "@deepseek-ai/dsh-subprocess";
4
+ import { createLazyRequire } from "@deepseek-ai/dsh-lazy-require";
5
5
  import { Deque } from "@deepseek-ai/dsh-deque";
6
6
  //#region lib/types/shells.js
7
7
  /** Shell selection and executable verification use the target execution provider. */
@@ -139,18 +139,161 @@ var TerminalFollower = class {
139
139
  }
140
140
  };
141
141
  //#endregion
142
+ //#region lib/types/retention.js
143
+ /** Window holds and conservative idle reclamation for one terminal owner. */
144
+ /** Exactly one owner orders holds, observation, and retryable process cleanup. */
145
+ var TerminalRetention = class {
146
+ policy;
147
+ inspect;
148
+ terminate;
149
+ failed;
150
+ lifetime = new AbortController();
151
+ holders = /* @__PURE__ */ new Set();
152
+ epoch = 0;
153
+ timer;
154
+ observation;
155
+ idle;
156
+ closing = false;
157
+ disposed = false;
158
+ cleanup;
159
+ /**
160
+ * @param policy - deployment timing choices.
161
+ * @param inspect - fresh shell and owned-job observation.
162
+ * @param terminate - mark the identity closed, await process quiescence, and remove its owner record.
163
+ * @param failed - diagnostic sink for failed automatic cleanup.
164
+ */
165
+ constructor(policy, inspect, terminate, failed) {
166
+ this.policy = policy;
167
+ this.inspect = inspect;
168
+ this.terminate = terminate;
169
+ this.failed = failed;
170
+ this.schedule(0);
171
+ }
172
+ /**
173
+ * Hold one terminal for one physical Remote stream, independently of screen subscriptions.
174
+ * @param signal - transport generation lifetime.
175
+ * @returns acknowledgement followed by an open stream until cancellation or terminal closure.
176
+ */
177
+ async *retain(signal) {
178
+ signal.throwIfAborted();
179
+ if (this.closing || this.disposed) throw new RemoteError("terminal/unavailable", "Terminal is closing or unavailable", {});
180
+ const holder = {};
181
+ const ended = Promise.withResolvers();
182
+ const combined = AbortSignal.any([signal, this.lifetime.signal]);
183
+ const release = () => {
184
+ if (!this.holders.delete(holder)) return;
185
+ combined.removeEventListener("abort", release);
186
+ this.invalidate();
187
+ ended.resolve();
188
+ this.schedule(0);
189
+ };
190
+ this.holders.add(holder);
191
+ this.invalidate();
192
+ this.cancelTimer();
193
+ combined.addEventListener("abort", release, { once: true });
194
+ try {
195
+ yield { type: "retained" };
196
+ await ended.promise;
197
+ } finally {
198
+ release();
199
+ }
200
+ }
201
+ /** Invalidate outstanding idle observations before accepting input. */
202
+ invalidate() {
203
+ this.epoch++;
204
+ this.idle = void 0;
205
+ }
206
+ /**
207
+ * Start or join cleanup; failure keeps the identity closed and schedules one retry.
208
+ * @returns after owned process cleanup succeeds, or rejects with its failure.
209
+ */
210
+ close() {
211
+ if (this.cleanup !== void 0) return this.cleanup;
212
+ this.closing = true;
213
+ this.invalidate();
214
+ this.lifetime.abort(/* @__PURE__ */ new Error("Terminal closed"));
215
+ this.cancelTimer();
216
+ this.cleanup = this.terminate().catch((error) => {
217
+ this.cleanup = void 0;
218
+ this.schedule(this.policy.cleanupRetryMs);
219
+ throw error;
220
+ });
221
+ return this.cleanup;
222
+ }
223
+ /**
224
+ * Stop timers and streams and await both observation and final cleanup.
225
+ * @returns after process quiescence; cleanup failure is reported to the disposing owner.
226
+ */
227
+ async dispose() {
228
+ this.disposed = true;
229
+ this.cancelTimer();
230
+ const observation = this.observation;
231
+ try {
232
+ await this.close();
233
+ } finally {
234
+ await observation;
235
+ }
236
+ }
237
+ cancelTimer() {
238
+ clearTimeout(this.timer);
239
+ this.timer = void 0;
240
+ }
241
+ schedule(delay) {
242
+ if (this.disposed || this.timer !== void 0) return;
243
+ if (!this.closing && (this.holders.size > 0 || this.policy.unattendedTimeoutMs === 0)) return;
244
+ const due = performance.now() + delay;
245
+ this.timer = setTimeout(() => {
246
+ this.timer = void 0;
247
+ const remaining = due - performance.now();
248
+ if (remaining > 0) {
249
+ this.schedule(remaining);
250
+ return;
251
+ }
252
+ if (this.closing) {
253
+ this.close().catch(this.failed);
254
+ return;
255
+ }
256
+ this.observe();
257
+ }, Math.min(delay, 2147483647));
258
+ this.timer.unref();
259
+ }
260
+ observe() {
261
+ if (this.observation !== void 0) return;
262
+ const epoch = this.epoch;
263
+ this.observation = (async () => {
264
+ let activity;
265
+ try {
266
+ activity = await this.inspect();
267
+ } catch (_activityUnavailable) {
268
+ activity = {
269
+ state: "unknown",
270
+ revision: 0
271
+ };
272
+ }
273
+ if (this.disposed || this.closing || this.holders.size > 0 || epoch !== this.epoch) return;
274
+ const now = performance.now();
275
+ if (activity.state !== "idle") {
276
+ this.idle = void 0;
277
+ return;
278
+ }
279
+ if (this.idle?.revision !== activity.revision || now - this.idle.observedAt > this.policy.activityPollIntervalMs * 2) this.idle = {
280
+ since: now,
281
+ observedAt: now,
282
+ revision: activity.revision
283
+ };
284
+ else this.idle.observedAt = now;
285
+ if (now - this.idle.since >= this.policy.unattendedTimeoutMs) await this.close();
286
+ })().catch(this.failed).finally(() => {
287
+ this.observation = void 0;
288
+ if (!this.closing) this.schedule(this.policy.activityPollIntervalMs);
289
+ });
290
+ }
291
+ };
292
+ //#endregion
142
293
  //#region lib/types/terminal.js
143
294
  /** One PTY, a bounded terminal emulator and its detachable browser followers. */
144
- const { Terminal, SerializeAddon } = loadXterm();
145
- function loadXterm() {
146
- const require = createRequire(import.meta.url);
147
- const { Terminal } = require("@xterm/headless");
148
- const { SerializeAddon } = require("@xterm/addon-serialize");
149
- return {
150
- Terminal,
151
- SerializeAddon
152
- };
153
- }
295
+ const requireHeadless = createLazyRequire("@xterm/headless", import.meta.url);
296
+ const requireSerialize = createLazyRequire("@xterm/addon-serialize", import.meta.url);
154
297
  /** Process lifetime is independent of follower and component lifetimes. */
155
298
  var BrowserTerminal = class {
156
299
  handle;
@@ -163,6 +306,7 @@ var BrowserTerminal = class {
163
306
  operations = Promise.resolve();
164
307
  drained;
165
308
  closing;
309
+ retention;
166
310
  controller;
167
311
  /**
168
312
  * @param handle - allocated terminal process range.
@@ -174,6 +318,8 @@ var BrowserTerminal = class {
174
318
  this.handle = handle;
175
319
  this.info = info;
176
320
  this.maxBufferedBytes = maxBufferedBytes;
321
+ const { Terminal } = requireHeadless();
322
+ const { SerializeAddon } = requireSerialize();
177
323
  this.screen = new Terminal({
178
324
  cols: info.cols,
179
325
  rows: info.rows,
@@ -185,6 +331,29 @@ var BrowserTerminal = class {
185
331
  this.drained = this.consume();
186
332
  }
187
333
  /**
334
+ * Start monitoring after this allocation is committed to its Session owner.
335
+ * @param policy - validated Host timing policy.
336
+ * @param closing - closes the id before any asynchronous termination.
337
+ * @param closed - removes the exact successfully terminated owner record.
338
+ * @param failed - diagnostic sink for background cleanup failure.
339
+ */
340
+ monitor(policy, closing, closed, failed) {
341
+ this.retention = new TerminalRetention(policy, () => this.handle.inspectActivity(), async () => {
342
+ closing();
343
+ await this.closeProcess();
344
+ closed();
345
+ }, failed);
346
+ }
347
+ /**
348
+ * Retain this committed process independently of output attachment.
349
+ * @param signal - physical window stream lifetime.
350
+ * @returns its hold acknowledgement and lifetime.
351
+ */
352
+ retain(signal) {
353
+ if (this.retention === void 0) throw new Error("Terminal has not been committed");
354
+ return this.retention.retain(signal);
355
+ }
356
+ /**
188
357
  * Attach with exclusive input control; an older attachment becomes read-only.
189
358
  * @param id - browser attachment identity.
190
359
  * @param signal - attachment cancellation; never terminates the process.
@@ -240,6 +409,7 @@ var BrowserTerminal = class {
240
409
  * @returns when the provider accepts the input.
241
410
  */
242
411
  write(id, data) {
412
+ this.retention?.invalidate();
243
413
  return this.enqueue(async () => {
244
414
  this.requireController(id);
245
415
  await this.handle.write(data);
@@ -287,6 +457,16 @@ var BrowserTerminal = class {
287
457
  * @returns after process cleanup and final output drainage; failures remain retryable.
288
458
  */
289
459
  close() {
460
+ return this.retention?.close() ?? this.closeProcess();
461
+ }
462
+ /**
463
+ * Stop unattended cleanup scheduling and await final process cleanup.
464
+ * @returns after terminal and monitor quiescence.
465
+ */
466
+ dispose() {
467
+ return this.retention?.dispose() ?? this.closeProcess();
468
+ }
469
+ closeProcess() {
290
470
  if (this.closing !== void 0) return this.closing;
291
471
  this.closing = (async () => {
292
472
  await this.handle.terminate();
@@ -402,6 +582,7 @@ let TerminalController = (() => {
402
582
  let _shells_decorators;
403
583
  let _list_decorators;
404
584
  let _create_decorators;
585
+ let _retain_decorators;
405
586
  let _follow_decorators;
406
587
  let _write_decorators;
407
588
  let _resize_decorators;
@@ -414,6 +595,7 @@ let TerminalController = (() => {
414
595
  _shells_decorators = [Remote];
415
596
  _list_decorators = [Remote];
416
597
  _create_decorators = [Remote];
598
+ _retain_decorators = [Remote({ mode: "stream" })];
417
599
  _follow_decorators = [Remote({ mode: "stream" })];
418
600
  _write_decorators = [Remote];
419
601
  _resize_decorators = [Remote];
@@ -463,6 +645,17 @@ let TerminalController = (() => {
463
645
  },
464
646
  metadata: _metadata
465
647
  }, null, _instanceExtraInitializers);
648
+ __esDecorate(this, null, _retain_decorators, {
649
+ kind: "method",
650
+ name: "retain",
651
+ static: false,
652
+ private: false,
653
+ access: {
654
+ has: (obj) => "retain" in obj,
655
+ get: (obj) => obj.retain
656
+ },
657
+ metadata: _metadata
658
+ }, null, _instanceExtraInitializers);
466
659
  __esDecorate(this, null, _follow_decorators, {
467
660
  kind: "method",
468
661
  name: "follow",
@@ -529,7 +722,6 @@ let TerminalController = (() => {
529
722
  static inject = [
530
723
  "subprocess",
531
724
  "sandboxPolicy",
532
- "sessionProjections",
533
725
  "typert"
534
726
  ];
535
727
  static Config = z.object({
@@ -552,7 +744,10 @@ let TerminalController = (() => {
552
744
  scrollback: z.number().step(1).min(0).default(1e3),
553
745
  maxBufferedBytes: z.number().step(1).min(1024).default(2 * 1024 * 1024),
554
746
  maxInputBytes: z.number().step(1).min(1).default(64 * 1024),
555
- disposeGraceMs: z.number().step(1).min(1).default(1e3)
747
+ disposeGraceMs: z.number().step(1).min(1).default(1e3),
748
+ unattendedTimeoutMs: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(72e5),
749
+ activityPollIntervalMs: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(3e4),
750
+ cleanupRetryMs: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(6e4)
556
751
  });
557
752
  owners = /* @__PURE__ */ new Map();
558
753
  lifetime = new AbortController();
@@ -563,15 +758,6 @@ let TerminalController = (() => {
563
758
  constructor(ctx, config) {
564
759
  super(ctx, "terminalController", { namespace: "terminal" });
565
760
  this.config = config;
566
- ctx.on("internal/dispatch", (_mode, eventName, args) => {
567
- if (eventName !== "session/event") return;
568
- const [session, event] = args;
569
- if (event.type !== "sandbox/mode") return;
570
- const owner = this.owners.get(session.id);
571
- if (owner === void 0 || owner.terminals.size + owner.pending.size + owner.allocations.size === 0) return;
572
- const current = ctx.sessionProjections.stateOf(session, "sandboxMode") ?? ctx.sandboxPolicy.defaultMode;
573
- if (event.data.mode !== current) throw new Error("Close browser terminals before changing the Session sandbox mode");
574
- }, { global: true });
575
761
  ctx.effect(() => async () => {
576
762
  this.lifetime.abort(/* @__PURE__ */ new Error("Terminal controller disposed"));
577
763
  const errors = (await Promise.allSettled([...this.owners].map(([id, owner]) => this.disposeOwner(id, owner)))).filter((result) => result.status === "rejected").map((result) => result.reason);
@@ -588,7 +774,7 @@ let TerminalController = (() => {
588
774
  signal.throwIfAborted();
589
775
  const { sandboxPolicy } = this.execution(agent);
590
776
  return {
591
- cwd: sandboxPolicy.resolve({ session: agent.session }).workspaceRoot,
777
+ cwd: agent.session.header.cwd ?? sandboxPolicy.workspaceRoot,
592
778
  maxInputBytes: this.config.maxInputBytes,
593
779
  maxCols: this.config.maxCols,
594
780
  maxRows: this.config.maxRows,
@@ -616,7 +802,7 @@ let TerminalController = (() => {
616
802
  return [...owner.terminals.values(), ...owner.allocations.values()].map((terminal) => terminal.info);
617
803
  }
618
804
  /**
619
- * Allocate an interactive shell once for a caller-generated identity.
805
+ * Allocate a user shell once for a caller-generated identity, without Agent sandbox or approval restrictions.
620
806
  * @param agent - Session owner supplied by the Gateway.
621
807
  * @param request - initial dimensions and idempotency identity.
622
808
  * @param signal - allocation cancellation; committed terminals survive disconnection.
@@ -637,7 +823,6 @@ let TerminalController = (() => {
637
823
  this.requireOpen(owner, request.id);
638
824
  return terminal.info;
639
825
  }
640
- if (owner.allocations.has(request.id)) throw new Error("Close the failed terminal allocation before creating it again");
641
826
  if (new Set([
642
827
  ...owner.terminals.keys(),
643
828
  ...owner.pending.keys(),
@@ -653,6 +838,13 @@ let TerminalController = (() => {
653
838
  const terminal = await allocation;
654
839
  owner.terminals.set(request.id, terminal);
655
840
  owner.allocations.delete(request.id);
841
+ terminal.monitor(this.config, () => {
842
+ owner.closedIds.add(request.id);
843
+ }, () => {
844
+ owner.terminals.delete(request.id);
845
+ }, (error) => {
846
+ this.ctx.logger.error("Browser terminal cleanup failed", error);
847
+ });
656
848
  this.requireOpen(owner, request.id);
657
849
  return terminal.info;
658
850
  } finally {
@@ -660,6 +852,19 @@ let TerminalController = (() => {
660
852
  }
661
853
  }
662
854
  /**
855
+ * Retain an existing terminal for a window without activating its Agent or taking input control.
856
+ * @param sessionId - owning Session identity, including an inactive saved layout.
857
+ * @param id - retained Host terminal identity.
858
+ * @param signal - physical Remote stream cancellation.
859
+ * @returns a hold acknowledgement followed by an open lifetime stream.
860
+ */
861
+ retain(sessionId, id, signal) {
862
+ const owner = this.owners.get(sessionId);
863
+ const terminal = owner?.terminals.get(id);
864
+ if (terminal === void 0 || owner?.closedIds.has(id) === true || owner?.lifetime.signal.aborted === true) throw new RemoteError("terminal/unavailable", "Terminal is closing or unavailable", {});
865
+ return terminal.retain(signal);
866
+ }
867
+ /**
663
868
  * Attach to a terminal without binding its process lifetime to the transport.
664
869
  * @param agent - Session owner supplied by the Gateway.
665
870
  * @param id - terminal identity.
@@ -723,7 +928,7 @@ let TerminalController = (() => {
723
928
  } else {
724
929
  const allocation = owner.allocations.get(id);
725
930
  if (allocation === void 0) return;
726
- await allocation.handle.terminate();
931
+ await allocation.cleanup.close();
727
932
  owner.allocations.delete(id);
728
933
  }
729
934
  }
@@ -750,7 +955,7 @@ let TerminalController = (() => {
750
955
  owner.lifetime.abort(/* @__PURE__ */ new Error("Terminal Session owner disposed"));
751
956
  owner.cleanup = (async () => {
752
957
  await Promise.allSettled(owner.pending.values());
753
- const errors = (await Promise.allSettled([...[...owner.terminals.values()].map((terminal) => terminal.close()), ...[...owner.allocations.values()].map((allocation) => allocation.handle.terminate())])).filter((result) => result.status === "rejected").map((result) => result.reason);
958
+ const errors = (await Promise.allSettled([...[...owner.terminals.values()].map((terminal) => terminal.dispose()), ...[...owner.allocations.values()].map((allocation) => allocation.cleanup.dispose())])).filter((result) => result.status === "rejected").map((result) => result.reason);
754
959
  if (errors.length > 0) throw new AggregateError(errors, "Session terminal cleanup failed");
755
960
  owner.terminals.clear();
756
961
  owner.allocations.clear();
@@ -763,11 +968,12 @@ let TerminalController = (() => {
763
968
  }
764
969
  terminal(agent, id) {
765
970
  const terminal = this.owners.get(agent.id)?.terminals.get(id);
766
- if (terminal === void 0) throw new Error("Terminal no longer exists in this Session");
971
+ if (terminal === void 0) throw new RemoteError("terminal/unavailable", "Terminal no longer exists in this Session", {});
972
+ this.requireOpen(this.owners.get(agent.id), id);
767
973
  return terminal;
768
974
  }
769
975
  requireOpen(owner, id) {
770
- if (owner.closedIds.has(id)) throw new Error("Terminal was closed in this Session");
976
+ if (owner.closedIds.has(id)) throw new RemoteError("terminal/unavailable", "Terminal was closed in this Session", {});
771
977
  }
772
978
  dimensions(cols, rows) {
773
979
  if (!Number.isSafeInteger(cols) || cols < 2 || cols > this.config.maxCols || !Number.isSafeInteger(rows) || rows < 1 || rows > this.config.maxRows) throw new Error("Terminal dimensions exceed the configured limits");
@@ -783,55 +989,51 @@ let TerminalController = (() => {
783
989
  }
784
990
  async spawn(agent, owner, request, signal) {
785
991
  const environment = this.environment(agent, signal);
786
- const { subprocess, sandboxPolicy } = this.execution(agent);
992
+ const { subprocess } = this.execution(agent);
787
993
  const shell = request.shellPath === void 0 ? await resolveShell(subprocess, this.config.shell, signal) : (await this.shells(agent, signal)).find((candidate) => candidate.path === request.shellPath);
788
994
  if (shell === void 0) throw new Error("Selected shell is not available in this execution environment");
789
- const policy = sandboxPolicy.resolve({ session: agent.session });
790
- let argv = [shell.path, ...shell.args];
791
- if (policy.mode !== "danger-full-access") {
792
- const sandbox = agent.ctx.get("sandbox");
793
- if (sandbox === void 0) throw new Error("The Session sandbox mode requires an execution sandbox provider");
794
- argv = (await sandbox.confine(argv, {
795
- ...policy,
796
- mode: policy.mode
797
- }, signal)).argv;
798
- }
799
995
  const handle = await subprocess.spawnTerminal({
800
- argv,
996
+ argv: [shell.path, ...shell.args],
801
997
  cwd: environment.cwd,
802
998
  cols: request.cols,
803
999
  rows: request.rows,
804
1000
  terminalType: "xterm-256color",
805
1001
  env: { DSH_SESSION_ID: agent.id },
1002
+ shellActivity: true,
806
1003
  graceMs: this.config.disposeGraceMs,
807
1004
  signal
808
1005
  });
809
- const allocation = {
810
- handle,
811
- info: {
812
- id: request.id,
813
- shell,
814
- title: shell.name,
815
- cwd: environment.cwd,
816
- cols: request.cols,
817
- rows: request.rows,
818
- state: "running",
819
- exitCode: null
820
- }
1006
+ const info = {
1007
+ id: request.id,
1008
+ shell,
1009
+ title: shell.name,
1010
+ cwd: environment.cwd,
1011
+ cols: request.cols,
1012
+ rows: request.rows,
1013
+ state: "running",
1014
+ exitCode: null
821
1015
  };
822
- owner.allocations.set(request.id, allocation);
823
1016
  try {
824
1017
  signal.throwIfAborted();
825
- return new BrowserTerminal(handle, allocation.info, this.config.scrollback, this.config.maxBufferedBytes);
1018
+ return new BrowserTerminal(handle, info, this.config.scrollback, this.config.maxBufferedBytes);
826
1019
  } catch (error) {
827
- allocation.info = {
828
- ...allocation.info,
829
- state: "failed",
830
- error: error instanceof Error ? error.message : String(error)
831
- };
832
- try {
1020
+ const cleanup = new TerminalRetention(this.config, handle.inspectActivity.bind(handle), async () => {
1021
+ owner.closedIds.add(request.id);
833
1022
  await handle.terminate();
834
1023
  owner.allocations.delete(request.id);
1024
+ }, (cleanupError) => {
1025
+ this.ctx.logger.error("Browser terminal allocation cleanup failed", cleanupError);
1026
+ });
1027
+ owner.allocations.set(request.id, {
1028
+ info: {
1029
+ ...info,
1030
+ state: "failed",
1031
+ error: error instanceof Error ? error.message : String(error)
1032
+ },
1033
+ cleanup
1034
+ });
1035
+ try {
1036
+ await cleanup.close();
835
1037
  } catch (cleanupError) {
836
1038
  throw new AggregateError([error, cleanupError], "Terminal allocation cleanup failed");
837
1039
  }