@wrongstack/acp 0.305.0 → 0.306.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.
@@ -18,6 +18,12 @@ export interface TerminalServerOptions {
18
18
  }
19
19
  export declare class TerminalServer {
20
20
  private readonly terminals;
21
+ /**
22
+ * Stable per-instance identifier for debug logs. 8 hex chars is enough
23
+ * to disambiguate concurrent TerminalServers in a trace; not meant to
24
+ * be cryptographically unique.
25
+ */
26
+ private readonly instanceId;
21
27
  private readonly projectRoot;
22
28
  private readonly commandTimeoutMs;
23
29
  private readonly outputByteLimit;
@@ -25,6 +31,7 @@ export declare class TerminalServer {
25
31
  private readonly maxTerminals;
26
32
  private readonly abortSignal;
27
33
  private readonly abortHandler;
34
+ private disposed;
28
35
  private nextId;
29
36
  constructor(opts: TerminalServerOptions);
30
37
  /** Spawn a new terminal. Returns the agent-facing id. */
@@ -63,7 +70,32 @@ export declare class TerminalServer {
63
70
  kill(terminalId: string): void;
64
71
  /** Kill the process if alive and remove the record. */
65
72
  release(terminalId: string): void;
66
- /** Kill all active terminals. Used on session close. */
73
+ /**
74
+ * Release all resources held by this server: kill every active terminal
75
+ * and detach the host `AbortSignal` listener.
76
+ *
77
+ * Idempotent — calling it multiple times is safe. Required because the
78
+ * previously-coded `releaseAll()` was the only path that removed the
79
+ * abort listener: if the host never called it (unhandled error path,
80
+ * host crash, GC of the session without explicit close), the listener
81
+ * pinned `this` (terminals Map, output buffers) for the lifetime of the
82
+ * signal. With `dispose()` this is no longer leak-prone.
83
+ *
84
+ * Also exposed as `[Symbol.dispose]` for `using` blocks in Node ≥ 22.
85
+ *
86
+ * RAM-leak audit 2026-08-11, MEDIUM (Finding 2).
87
+ */
88
+ dispose(): void;
89
+ /** Alias for `dispose()` — enables `using new TerminalServer(...)`. */
90
+ [Symbol.dispose](): void;
91
+ /**
92
+ * Kill all active terminals. Used on session close.
93
+ *
94
+ * @deprecated Prefer `dispose()` (or `using { … }` via `Symbol.dispose`).
95
+ * `releaseAll` is retained as a delegated wrapper for callers that still
96
+ * reference it; new code should call `dispose()` directly so the
97
+ * host-signal listener is removed unconditionally.
98
+ */
67
99
  releaseAll(): void;
68
100
  private resolveCwd;
69
101
  private buildEnv;
package/dist/client.js CHANGED
@@ -515,26 +515,36 @@ function makePermissionPolicy(decide) {
515
515
 
516
516
  // src/client/terminal-server.ts
517
517
  import { spawn } from "node:child_process";
518
+ import { randomBytes as randomBytes2 } from "node:crypto";
518
519
  import { realpathSync as realpathSync2 } from "node:fs";
519
520
  import * as path2 from "node:path";
520
521
  import { buildChildEnv } from "@wrongstack/core/utils";
521
522
  import { treeKill as treeKill2 } from "@wrongstack/core/utils/tree-kill";
522
523
  var EMPTY_BUFFER = Buffer.alloc(0);
524
+ var DEBUG_DISPOSE = typeof process !== "undefined" && !!process.env?.WRONGSTACK_DEBUG && process.env.WRONGSTACK_DEBUG !== "0" && process.env.WRONGSTACK_DEBUG !== "false";
523
525
  var TerminalServer = class {
524
526
  terminals = /* @__PURE__ */ new Map();
527
+ /**
528
+ * Stable per-instance identifier for debug logs. 8 hex chars is enough
529
+ * to disambiguate concurrent TerminalServers in a trace; not meant to
530
+ * be cryptographically unique.
531
+ */
532
+ instanceId;
525
533
  projectRoot;
526
534
  commandTimeoutMs;
527
535
  outputByteLimit;
528
536
  maxOutputByteLimit;
529
537
  maxTerminals;
530
538
  abortSignal;
531
- abortHandler = () => this.releaseAll();
539
+ abortHandler = () => this.dispose();
540
+ disposed = false;
532
541
  nextId = 1;
533
542
  constructor(opts) {
534
543
  this.projectRoot = path2.resolve(opts.projectRoot);
535
544
  this.commandTimeoutMs = opts.commandTimeoutMs ?? 5 * 6e4;
536
545
  this.outputByteLimit = opts.outputByteLimit ?? 1024 * 1024;
537
546
  this.maxOutputByteLimit = opts.maxOutputByteLimit ?? 16 * 1024 * 1024;
547
+ this.instanceId = `term_srv_${randomBytes2(4).toString("hex")}`;
538
548
  this.maxTerminals = this.clampFiniteInt(opts.maxTerminals, 32);
539
549
  this.abortSignal = opts.signal;
540
550
  if (opts.signal) {
@@ -543,6 +553,11 @@ var TerminalServer = class {
543
553
  }
544
554
  /** Spawn a new terminal. Returns the agent-facing id. */
545
555
  create(params) {
556
+ if (this.disposed) {
557
+ throw new Error(
558
+ "TerminalServer is disposed \u2014 create a new TerminalServer instead of reusing this one"
559
+ );
560
+ }
546
561
  if (this.terminals.size >= this.maxTerminals) {
547
562
  throw new Error(
548
563
  `terminal limit reached (${this.maxTerminals}); release an existing terminal before creating another`
@@ -697,13 +712,56 @@ var TerminalServer = class {
697
712
  treeKill2(state.proc, { force: true });
698
713
  this.terminals.delete(terminalId);
699
714
  }
700
- /** Kill all active terminals. Used on session close. */
701
- releaseAll() {
715
+ /**
716
+ * Release all resources held by this server: kill every active terminal
717
+ * and detach the host `AbortSignal` listener.
718
+ *
719
+ * Idempotent — calling it multiple times is safe. Required because the
720
+ * previously-coded `releaseAll()` was the only path that removed the
721
+ * abort listener: if the host never called it (unhandled error path,
722
+ * host crash, GC of the session without explicit close), the listener
723
+ * pinned `this` (terminals Map, output buffers) for the lifetime of the
724
+ * signal. With `dispose()` this is no longer leak-prone.
725
+ *
726
+ * Also exposed as `[Symbol.dispose]` for `using` blocks in Node ≥ 22.
727
+ *
728
+ * RAM-leak audit 2026-08-11, MEDIUM (Finding 2).
729
+ */
730
+ dispose() {
731
+ if (this.disposed) return;
732
+ if (DEBUG_DISPOSE) {
733
+ const activeChildren = this.terminals.size;
734
+ console.debug(
735
+ JSON.stringify({
736
+ event: "terminal_server.disposed",
737
+ instanceId: this.instanceId,
738
+ activeChildren,
739
+ hadSignal: this.abortSignal !== void 0,
740
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
741
+ })
742
+ );
743
+ }
744
+ this.disposed = true;
702
745
  this.abortSignal?.removeEventListener("abort", this.abortHandler);
703
746
  for (const id of [...this.terminals.keys()]) {
704
747
  this.release(id);
705
748
  }
706
749
  }
750
+ /** Alias for `dispose()` — enables `using new TerminalServer(...)`. */
751
+ [Symbol.dispose]() {
752
+ this.dispose();
753
+ }
754
+ /**
755
+ * Kill all active terminals. Used on session close.
756
+ *
757
+ * @deprecated Prefer `dispose()` (or `using { … }` via `Symbol.dispose`).
758
+ * `releaseAll` is retained as a delegated wrapper for callers that still
759
+ * reference it; new code should call `dispose()` directly so the
760
+ * host-signal listener is removed unconditionally.
761
+ */
762
+ releaseAll() {
763
+ this.dispose();
764
+ }
707
765
  resolveCwd(cwd) {
708
766
  if (!cwd) return this.projectRoot;
709
767
  const resolved = path2.resolve(cwd);
@@ -1966,7 +2024,7 @@ var ACPSession = class _ACPSession {
1966
2024
  this.callbackAbort.abort();
1967
2025
  this.promptCallbackAbort?.abort();
1968
2026
  this.promptCallbackAbort = null;
1969
- this.terminalServer.releaseAll();
2027
+ this.terminalServer.dispose();
1970
2028
  if (this.sessionId && this.agentCapabilities.sessionCapabilities?.close) {
1971
2029
  try {
1972
2030
  await this.closeSession();
package/dist/index.js CHANGED
@@ -1870,26 +1870,36 @@ function makePermissionPolicy(decide) {
1870
1870
 
1871
1871
  // src/client/terminal-server.ts
1872
1872
  import { spawn } from "node:child_process";
1873
+ import { randomBytes as randomBytes2 } from "node:crypto";
1873
1874
  import { realpathSync as realpathSync2 } from "node:fs";
1874
1875
  import * as path3 from "node:path";
1875
1876
  import { buildChildEnv } from "@wrongstack/core/utils";
1876
1877
  import { treeKill as treeKill2 } from "@wrongstack/core/utils/tree-kill";
1877
1878
  var EMPTY_BUFFER = Buffer.alloc(0);
1879
+ var DEBUG_DISPOSE = typeof process !== "undefined" && !!process.env?.WRONGSTACK_DEBUG && process.env.WRONGSTACK_DEBUG !== "0" && process.env.WRONGSTACK_DEBUG !== "false";
1878
1880
  var TerminalServer = class {
1879
1881
  terminals = /* @__PURE__ */ new Map();
1882
+ /**
1883
+ * Stable per-instance identifier for debug logs. 8 hex chars is enough
1884
+ * to disambiguate concurrent TerminalServers in a trace; not meant to
1885
+ * be cryptographically unique.
1886
+ */
1887
+ instanceId;
1880
1888
  projectRoot;
1881
1889
  commandTimeoutMs;
1882
1890
  outputByteLimit;
1883
1891
  maxOutputByteLimit;
1884
1892
  maxTerminals;
1885
1893
  abortSignal;
1886
- abortHandler = () => this.releaseAll();
1894
+ abortHandler = () => this.dispose();
1895
+ disposed = false;
1887
1896
  nextId = 1;
1888
1897
  constructor(opts) {
1889
1898
  this.projectRoot = path3.resolve(opts.projectRoot);
1890
1899
  this.commandTimeoutMs = opts.commandTimeoutMs ?? 5 * 6e4;
1891
1900
  this.outputByteLimit = opts.outputByteLimit ?? 1024 * 1024;
1892
1901
  this.maxOutputByteLimit = opts.maxOutputByteLimit ?? 16 * 1024 * 1024;
1902
+ this.instanceId = `term_srv_${randomBytes2(4).toString("hex")}`;
1893
1903
  this.maxTerminals = this.clampFiniteInt(opts.maxTerminals, 32);
1894
1904
  this.abortSignal = opts.signal;
1895
1905
  if (opts.signal) {
@@ -1898,6 +1908,11 @@ var TerminalServer = class {
1898
1908
  }
1899
1909
  /** Spawn a new terminal. Returns the agent-facing id. */
1900
1910
  create(params) {
1911
+ if (this.disposed) {
1912
+ throw new Error(
1913
+ "TerminalServer is disposed \u2014 create a new TerminalServer instead of reusing this one"
1914
+ );
1915
+ }
1901
1916
  if (this.terminals.size >= this.maxTerminals) {
1902
1917
  throw new Error(
1903
1918
  `terminal limit reached (${this.maxTerminals}); release an existing terminal before creating another`
@@ -2052,13 +2067,56 @@ var TerminalServer = class {
2052
2067
  treeKill2(state.proc, { force: true });
2053
2068
  this.terminals.delete(terminalId);
2054
2069
  }
2055
- /** Kill all active terminals. Used on session close. */
2056
- releaseAll() {
2070
+ /**
2071
+ * Release all resources held by this server: kill every active terminal
2072
+ * and detach the host `AbortSignal` listener.
2073
+ *
2074
+ * Idempotent — calling it multiple times is safe. Required because the
2075
+ * previously-coded `releaseAll()` was the only path that removed the
2076
+ * abort listener: if the host never called it (unhandled error path,
2077
+ * host crash, GC of the session without explicit close), the listener
2078
+ * pinned `this` (terminals Map, output buffers) for the lifetime of the
2079
+ * signal. With `dispose()` this is no longer leak-prone.
2080
+ *
2081
+ * Also exposed as `[Symbol.dispose]` for `using` blocks in Node ≥ 22.
2082
+ *
2083
+ * RAM-leak audit 2026-08-11, MEDIUM (Finding 2).
2084
+ */
2085
+ dispose() {
2086
+ if (this.disposed) return;
2087
+ if (DEBUG_DISPOSE) {
2088
+ const activeChildren = this.terminals.size;
2089
+ console.debug(
2090
+ JSON.stringify({
2091
+ event: "terminal_server.disposed",
2092
+ instanceId: this.instanceId,
2093
+ activeChildren,
2094
+ hadSignal: this.abortSignal !== void 0,
2095
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2096
+ })
2097
+ );
2098
+ }
2099
+ this.disposed = true;
2057
2100
  this.abortSignal?.removeEventListener("abort", this.abortHandler);
2058
2101
  for (const id of [...this.terminals.keys()]) {
2059
2102
  this.release(id);
2060
2103
  }
2061
2104
  }
2105
+ /** Alias for `dispose()` — enables `using new TerminalServer(...)`. */
2106
+ [Symbol.dispose]() {
2107
+ this.dispose();
2108
+ }
2109
+ /**
2110
+ * Kill all active terminals. Used on session close.
2111
+ *
2112
+ * @deprecated Prefer `dispose()` (or `using { … }` via `Symbol.dispose`).
2113
+ * `releaseAll` is retained as a delegated wrapper for callers that still
2114
+ * reference it; new code should call `dispose()` directly so the
2115
+ * host-signal listener is removed unconditionally.
2116
+ */
2117
+ releaseAll() {
2118
+ this.dispose();
2119
+ }
2062
2120
  resolveCwd(cwd) {
2063
2121
  if (!cwd) return this.projectRoot;
2064
2122
  const resolved = path3.resolve(cwd);
@@ -3321,7 +3379,7 @@ var ACPSession = class _ACPSession {
3321
3379
  this.callbackAbort.abort();
3322
3380
  this.promptCallbackAbort?.abort();
3323
3381
  this.promptCallbackAbort = null;
3324
- this.terminalServer.releaseAll();
3382
+ this.terminalServer.dispose();
3325
3383
  if (this.sessionId && this.agentCapabilities.sessionCapabilities?.close) {
3326
3384
  try {
3327
3385
  await this.closeSession();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrongstack/acp",
3
- "version": "0.305.0",
3
+ "version": "0.306.0",
4
4
  "license": "MIT",
5
5
  "description": "ACP (Agent Client Protocol) integration for WrongStack — client + agent support",
6
6
  "keywords": [
@@ -52,7 +52,7 @@
52
52
  ],
53
53
  "dependencies": {
54
54
  "@agentclientprotocol/sdk": "^1.3.0",
55
- "@wrongstack/core": "0.305.0"
55
+ "@wrongstack/core": "0.306.0"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@types/node": "^26.1.2",