agl 1.0.17 → 21.0.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.
Files changed (277) hide show
  1. package/.smartconfig.json +55 -0
  2. package/changelog.md +1026 -0
  3. package/cli.js +14 -0
  4. package/dist_serve/bundle.js +44778 -0
  5. package/dist_serve/index.html +13 -0
  6. package/dist_ts/00_commitinfo_data.d.ts +8 -0
  7. package/dist_ts/00_commitinfo_data.js +9 -0
  8. package/dist_ts/classes.authmodels.d.ts +126 -0
  9. package/dist_ts/classes.authmodels.js +1422 -0
  10. package/dist_ts/classes.authstore.d.ts +160 -0
  11. package/dist_ts/classes.authstore.js +2483 -0
  12. package/dist_ts/classes.browserresourcehost.d.ts +85 -0
  13. package/dist_ts/classes.browserresourcehost.js +912 -0
  14. package/dist_ts/classes.cli.d.ts +21 -0
  15. package/dist_ts/classes.cli.js +1082 -0
  16. package/dist_ts/classes.config.d.ts +23 -0
  17. package/dist_ts/classes.config.js +267 -0
  18. package/dist_ts/classes.controller.d.ts +523 -0
  19. package/dist_ts/classes.controller.js +10611 -0
  20. package/dist_ts/classes.controllermanagement.d.ts +32 -0
  21. package/dist_ts/classes.controllermanagement.js +222 -0
  22. package/dist_ts/classes.draftmanager.d.ts +30 -0
  23. package/dist_ts/classes.draftmanager.js +179 -0
  24. package/dist_ts/classes.embeddeddb.d.ts +30 -0
  25. package/dist_ts/classes.embeddeddb.js +159 -0
  26. package/dist_ts/classes.flexframedtransport.d.ts +56 -0
  27. package/dist_ts/classes.flexframedtransport.js +275 -0
  28. package/dist_ts/classes.flexmodels.private.d.ts +231 -0
  29. package/dist_ts/classes.flexmodels.private.js +985 -0
  30. package/dist_ts/classes.flexmodels.public.d.ts +207 -0
  31. package/dist_ts/classes.flexmodels.public.js +885 -0
  32. package/dist_ts/classes.flexprojectionreader.d.ts +89 -0
  33. package/dist_ts/classes.flexprojectionreader.js +580 -0
  34. package/dist_ts/classes.flexservice.d.ts +196 -0
  35. package/dist_ts/classes.flexservice.js +3704 -0
  36. package/dist_ts/classes.flexstore.d.ts +174 -0
  37. package/dist_ts/classes.flexstore.js +3400 -0
  38. package/dist_ts/classes.flexsupervisor.d.ts +105 -0
  39. package/dist_ts/classes.flexsupervisor.js +1191 -0
  40. package/dist_ts/classes.gitreversion.d.ts +431 -0
  41. package/dist_ts/classes.gitreversion.js +10592 -0
  42. package/dist_ts/classes.harnessoperationmanager.d.ts +113 -0
  43. package/dist_ts/classes.harnessoperationmanager.js +602 -0
  44. package/dist_ts/classes.opencodeclient.d.ts +146 -0
  45. package/dist_ts/classes.opencodeclient.js +3003 -0
  46. package/dist_ts/classes.opencodesupervisor.d.ts +60 -0
  47. package/dist_ts/classes.opencodesupervisor.js +1003 -0
  48. package/dist_ts/classes.passkeymanager.d.ts +34 -0
  49. package/dist_ts/classes.passkeymanager.js +456 -0
  50. package/dist_ts/classes.processinspection.d.ts +50 -0
  51. package/dist_ts/classes.processinspection.js +484 -0
  52. package/dist_ts/classes.resourcecoordinator.d.ts +64 -0
  53. package/dist_ts/classes.resourcecoordinator.js +294 -0
  54. package/dist_ts/classes.sessionintelligencereader.d.ts +13 -0
  55. package/dist_ts/classes.sessionintelligencereader.js +40 -0
  56. package/dist_ts/classes.systemmetricssampler.d.ts +53 -0
  57. package/dist_ts/classes.systemmetricssampler.js +168 -0
  58. package/dist_ts/classes.terminalmanager.d.ts +95 -0
  59. package/dist_ts/classes.terminalmanager.js +981 -0
  60. package/dist_ts/classes.terminalresourcehost.d.ts +20 -0
  61. package/dist_ts/classes.terminalresourcehost.js +84 -0
  62. package/dist_ts/classes.upgradecoordinator.d.ts +244 -0
  63. package/dist_ts/classes.upgradecoordinator.js +2155 -0
  64. package/dist_ts/classes.upgradetransaction.d.ts +92 -0
  65. package/dist_ts/classes.upgradetransaction.js +2125 -0
  66. package/dist_ts/classes.uploadmanager.d.ts +24 -0
  67. package/dist_ts/classes.uploadmanager.js +210 -0
  68. package/dist_ts/classes.validation.d.ts +91 -0
  69. package/dist_ts/classes.validation.js +1087 -0
  70. package/dist_ts/flexharness.child.d.ts +1 -0
  71. package/dist_ts/flexharness.child.js +28 -0
  72. package/dist_ts/functions.canonicaldirectory.d.ts +21 -0
  73. package/dist_ts/functions.canonicaldirectory.js +144 -0
  74. package/dist_ts/functions.controllerdataroot.d.ts +15 -0
  75. package/dist_ts/functions.controllerdataroot.js +76 -0
  76. package/dist_ts/functions.embeddeddb.d.ts +2 -0
  77. package/dist_ts/functions.embeddeddb.js +21 -0
  78. package/dist_ts/functions.flexcredentialstore.d.ts +16 -0
  79. package/dist_ts/functions.flexcredentialstore.js +93 -0
  80. package/dist_ts/functions.opencodeauth.d.ts +3 -0
  81. package/dist_ts/functions.opencodeauth.js +31 -0
  82. package/dist_ts/functions.runtimeenvironment.d.ts +2 -0
  83. package/dist_ts/functions.runtimeenvironment.js +79 -0
  84. package/dist_ts/functions.upgradefollower.d.ts +3 -0
  85. package/dist_ts/functions.upgradefollower.js +95 -0
  86. package/dist_ts/index.d.ts +24 -0
  87. package/dist_ts/index.js +25 -0
  88. package/dist_ts/interfaces.auth.d.ts +181 -0
  89. package/dist_ts/interfaces.auth.js +82 -0
  90. package/dist_ts/interfaces.config.d.ts +34 -0
  91. package/dist_ts/interfaces.config.js +2 -0
  92. package/dist_ts/interfaces.flexipc.d.ts +939 -0
  93. package/dist_ts/interfaces.flexipc.js +1492 -0
  94. package/dist_ts/interfaces.gitreversion.d.ts +246 -0
  95. package/dist_ts/interfaces.gitreversion.js +2 -0
  96. package/dist_ts/interfaces.lifecycle.d.ts +37 -0
  97. package/dist_ts/interfaces.lifecycle.js +2 -0
  98. package/dist_ts/interfaces.opencode.d.ts +101 -0
  99. package/dist_ts/interfaces.opencode.js +3 -0
  100. package/dist_ts/interfaces.projects.d.ts +156 -0
  101. package/dist_ts/interfaces.projects.js +5 -0
  102. package/dist_ts/plugins.d.ts +36 -0
  103. package/dist_ts/plugins.js +40 -0
  104. package/dist_ts_interfaces/index.d.ts +2 -0
  105. package/dist_ts_interfaces/index.js +3 -0
  106. package/dist_ts_interfaces/interfaces.d.ts +747 -0
  107. package/dist_ts_interfaces/interfaces.js +47 -0
  108. package/dist_ts_interfaces/plugins.d.ts +4 -0
  109. package/dist_ts_interfaces/plugins.js +3 -0
  110. package/dist_ts_interfaces/requests.d.ts +806 -0
  111. package/dist_ts_interfaces/requests.js +2 -0
  112. package/dist_ts_migration/classes.documentmigrationrunner.d.ts +8 -0
  113. package/dist_ts_migration/classes.documentmigrationrunner.js +24 -0
  114. package/dist_ts_migration/classes.flexmigrationrunner.d.ts +11 -0
  115. package/dist_ts_migration/classes.flexmigrationrunner.js +25 -0
  116. package/dist_ts_migration/classes.flexpostharnessmigrationrunner.d.ts +10 -0
  117. package/dist_ts_migration/classes.flexpostharnessmigrationrunner.js +15 -0
  118. package/dist_ts_migration/classes.migrationrunner.d.ts +8 -0
  119. package/dist_ts_migration/classes.migrationrunner.js +34 -0
  120. package/dist_ts_migration/index.d.ts +12 -0
  121. package/dist_ts_migration/index.js +13 -0
  122. package/dist_ts_migration/v10_flexharnessv3migration.d.ts +11 -0
  123. package/dist_ts_migration/v10_flexharnessv3migration.js +23 -0
  124. package/dist_ts_migration/v13_flexprojectionorder.d.ts +11 -0
  125. package/dist_ts_migration/v13_flexprojectionorder.js +23 -0
  126. package/dist_ts_migration/v16_flexprovidercredentials.d.ts +22 -0
  127. package/dist_ts_migration/v16_flexprovidercredentials.js +149 -0
  128. package/dist_ts_migration/v18_resourcelayout.d.ts +22 -0
  129. package/dist_ts_migration/v18_resourcelayout.js +23 -0
  130. package/dist_ts_migration/v18_resourcelayoutmigration.d.ts +6 -0
  131. package/dist_ts_migration/v18_resourcelayoutmigration.js +61 -0
  132. package/dist_ts_migration/v19_flexprojectmanagement.d.ts +11 -0
  133. package/dist_ts_migration/v19_flexprojectmanagement.js +21 -0
  134. package/dist_ts_migration/v1_embeddeddatabaselocation.d.ts +28 -0
  135. package/dist_ts_migration/v1_embeddeddatabaselocation.js +415 -0
  136. package/dist_ts_migration/v22_projectfilesystemidentitymigration.d.ts +7 -0
  137. package/dist_ts_migration/v22_projectfilesystemidentitymigration.js +105 -0
  138. package/dist_ts_migration/v2_controllerdataroot.d.ts +130 -0
  139. package/dist_ts_migration/v2_controllerdataroot.js +1887 -0
  140. package/dist_ts_migration/v6_protocoldocumentmigration.d.ts +7 -0
  141. package/dist_ts_migration/v6_protocoldocumentmigration.js +100 -0
  142. package/dist_ts_migration/v6_protocoldocuments.d.ts +7 -0
  143. package/dist_ts_migration/v6_protocoldocuments.js +138 -0
  144. package/html/index.html +13 -0
  145. package/{LICENSE → license.md} +2 -2
  146. package/package.json +86 -48
  147. package/readme.md +480 -0
  148. package/readme.plan.md +188 -0
  149. package/ts/00_commitinfo_data.ts +8 -0
  150. package/ts/classes.authmodels.ts +1245 -0
  151. package/ts/classes.authstore.ts +3274 -0
  152. package/ts/classes.browserresourcehost.ts +1205 -0
  153. package/ts/classes.cli.ts +1254 -0
  154. package/ts/classes.config.ts +351 -0
  155. package/ts/classes.controller.ts +14426 -0
  156. package/ts/classes.controllermanagement.ts +330 -0
  157. package/ts/classes.draftmanager.ts +232 -0
  158. package/ts/classes.embeddeddb.ts +166 -0
  159. package/ts/classes.flexframedtransport.ts +283 -0
  160. package/ts/classes.flexmodels.private.ts +823 -0
  161. package/ts/classes.flexmodels.public.ts +732 -0
  162. package/ts/classes.flexprojectionreader.ts +816 -0
  163. package/ts/classes.flexservice.ts +4302 -0
  164. package/ts/classes.flexstore.ts +4565 -0
  165. package/ts/classes.flexsupervisor.ts +1453 -0
  166. package/ts/classes.gitreversion.ts +13353 -0
  167. package/ts/classes.harnessoperationmanager.ts +870 -0
  168. package/ts/classes.opencodeclient.ts +4110 -0
  169. package/ts/classes.opencodesupervisor.ts +1280 -0
  170. package/ts/classes.passkeymanager.ts +570 -0
  171. package/ts/classes.processinspection.ts +623 -0
  172. package/ts/classes.resourcecoordinator.ts +436 -0
  173. package/ts/classes.sessionintelligencereader.ts +52 -0
  174. package/ts/classes.systemmetricssampler.ts +215 -0
  175. package/ts/classes.terminalmanager.ts +1207 -0
  176. package/ts/classes.terminalresourcehost.ts +122 -0
  177. package/ts/classes.upgradecoordinator.ts +2675 -0
  178. package/ts/classes.upgradetransaction.ts +2900 -0
  179. package/ts/classes.uploadmanager.ts +240 -0
  180. package/ts/classes.validation.ts +1409 -0
  181. package/ts/flexharness.child.ts +35 -0
  182. package/ts/functions.canonicaldirectory.ts +213 -0
  183. package/ts/functions.controllerdataroot.ts +107 -0
  184. package/ts/functions.embeddeddb.ts +22 -0
  185. package/ts/functions.flexcredentialstore.ts +128 -0
  186. package/ts/functions.opencodeauth.ts +34 -0
  187. package/ts/functions.runtimeenvironment.ts +90 -0
  188. package/ts/functions.upgradefollower.ts +115 -0
  189. package/ts/index.ts +36 -6
  190. package/ts/interfaces.auth.ts +316 -0
  191. package/ts/interfaces.config.ts +37 -0
  192. package/ts/interfaces.flexipc.ts +2586 -0
  193. package/ts/interfaces.gitreversion.ts +336 -0
  194. package/ts/interfaces.lifecycle.ts +46 -0
  195. package/ts/interfaces.opencode.ts +153 -0
  196. package/ts/interfaces.projects.ts +182 -0
  197. package/ts/plugins.ts +64 -0
  198. package/ts_interfaces/index.ts +2 -0
  199. package/ts_interfaces/interfaces.ts +827 -0
  200. package/ts_interfaces/plugins.ts +15 -0
  201. package/ts_interfaces/requests.ts +1186 -0
  202. package/ts_migration/classes.documentmigrationrunner.ts +32 -0
  203. package/ts_migration/classes.flexmigrationrunner.ts +33 -0
  204. package/ts_migration/classes.flexpostharnessmigrationrunner.ts +22 -0
  205. package/ts_migration/classes.migrationrunner.ts +40 -0
  206. package/ts_migration/index.ts +12 -0
  207. package/ts_migration/v10_flexharnessv3migration.ts +35 -0
  208. package/ts_migration/v13_flexprojectionorder.ts +33 -0
  209. package/ts_migration/v16_flexprovidercredentials.ts +196 -0
  210. package/ts_migration/v18_resourcelayout.ts +55 -0
  211. package/ts_migration/v18_resourcelayoutmigration.ts +68 -0
  212. package/ts_migration/v19_flexprojectmanagement.ts +28 -0
  213. package/ts_migration/v1_embeddeddatabaselocation.ts +560 -0
  214. package/ts_migration/v22_projectfilesystemidentitymigration.ts +121 -0
  215. package/ts_migration/v2_controllerdataroot.ts +2349 -0
  216. package/ts_migration/v6_protocoldocumentmigration.ts +144 -0
  217. package/ts_migration/v6_protocoldocuments.ts +198 -0
  218. package/ts_web/00_commitinfo_data.ts +8 -0
  219. package/ts_web/classes.controllersocketclient.ts +517 -0
  220. package/ts_web/classes.sessiondraftsync.ts +390 -0
  221. package/ts_web/elements.harnesscontrollerapp.ts +11494 -0
  222. package/ts_web/index.ts +2 -0
  223. package/ts_web/plugins.ts +31 -0
  224. package/.gitlab-ci.yml +0 -71
  225. package/.npmignore +0 -5
  226. package/README.md +0 -40
  227. package/dist/cli.js +0 -2
  228. package/dist/index.d.ts +0 -0
  229. package/dist/index.js +0 -9
  230. package/dist/mod_bundle/index.d.ts +0 -1
  231. package/dist/mod_bundle/index.js +0 -18
  232. package/dist/mod_bundle/mod.plugins.d.ts +0 -4
  233. package/dist/mod_bundle/mod.plugins.js +0 -11
  234. package/dist/mod_install/index.d.ts +0 -4
  235. package/dist/mod_install/index.js +0 -21
  236. package/dist/mod_install/mod.plugins.d.ts +0 -1
  237. package/dist/mod_install/mod.plugins.js +0 -7
  238. package/dist/mod_standard/index.d.ts +0 -4
  239. package/dist/mod_standard/index.js +0 -21
  240. package/dist/mod_standard/mod.browser.d.ts +0 -6
  241. package/dist/mod_standard/mod.browser.js +0 -14
  242. package/dist/mod_standard/mod.clean.d.ts +0 -4
  243. package/dist/mod_standard/mod.clean.js +0 -12
  244. package/dist/mod_standard/mod.compile.d.ts +0 -4
  245. package/dist/mod_standard/mod.compile.js +0 -21
  246. package/dist/mod_standard/mod.plugins.d.ts +0 -8
  247. package/dist/mod_standard/mod.plugins.js +0 -19
  248. package/dist/mod_standard/mod.test.d.ts +0 -4
  249. package/dist/mod_standard/mod.test.js +0 -12
  250. package/dist/plr.cli.d.ts +0 -3
  251. package/dist/plr.cli.js +0 -57
  252. package/dist/plr.config.d.ts +0 -11
  253. package/dist/plr.config.js +0 -26
  254. package/dist/plr.mods.d.ts +0 -17
  255. package/dist/plr.mods.js +0 -17
  256. package/dist/plr.paths.d.ts +0 -19
  257. package/dist/plr.paths.js +0 -26
  258. package/dist/plr.plugins.d.ts +0 -8
  259. package/dist/plr.plugins.js +0 -16
  260. package/npmextra.json +0 -10
  261. package/ts/mod_bundle/index.ts +0 -20
  262. package/ts/mod_bundle/mod.plugins.ts +0 -9
  263. package/ts/mod_install/index.ts +0 -20
  264. package/ts/mod_install/mod.plugins.ts +0 -1
  265. package/ts/mod_standard/index.ts +0 -23
  266. package/ts/mod_standard/mod.browser.ts +0 -17
  267. package/ts/mod_standard/mod.clean.ts +0 -13
  268. package/ts/mod_standard/mod.compile.ts +0 -23
  269. package/ts/mod_standard/mod.plugins.ts +0 -17
  270. package/ts/mod_standard/mod.test.ts +0 -13
  271. package/ts/plr.cli.ts +0 -60
  272. package/ts/plr.config.ts +0 -36
  273. package/ts/plr.mods.ts +0 -25
  274. package/ts/plr.paths.ts +0 -29
  275. package/ts/plr.plugins.ts +0 -17
  276. package/tslint.json +0 -3
  277. package/yarn.lock +0 -3464
@@ -0,0 +1,1207 @@
1
+ import * as plugins from './plugins.js';
2
+ import type { IControllerTerminal, TControllerTerminalId } from '../ts_interfaces/index.js';
3
+ import type { IControllerResourceDocument } from './interfaces.projects.js';
4
+ import { createSanitizedRuntimeEnvironment } from './functions.runtimeenvironment.js';
5
+
6
+ export interface ITerminalOutputPayload {
7
+ terminalId: TControllerTerminalId;
8
+ dataBase64: string;
9
+ reset?: boolean;
10
+ ended?: boolean;
11
+ }
12
+
13
+ export interface IControllerPtySpawner {
14
+ ensurePtySupport(): Promise<void>;
15
+ execSpawnStreamingInteractiveControlPty(
16
+ commandArg: string,
17
+ argsArg?: string[],
18
+ optionsArg?: plugins.smartshell.IPtyDirectSpawnOptions,
19
+ ): Promise<plugins.smartshell.IExecResultPtyStreaming>;
20
+ }
21
+
22
+ export interface IControllerTerminalShell {
23
+ executable: string;
24
+ args: string[];
25
+ environment: NodeJS.ProcessEnv;
26
+ }
27
+
28
+ export interface IControllerTerminalManagerOptions {
29
+ resolveProjectDirectory: (projectIdArg: string) => Promise<string>;
30
+ sendOutput: (peerIdArg: string, payloadArg: ITerminalOutputPayload) => Promise<void>;
31
+ onTerminalsChanged: (projectIdArg: string) => void;
32
+ onTerminalStopped: (
33
+ projectIdArg: string,
34
+ terminalIdArg: TControllerTerminalId,
35
+ exitCodeArg?: number,
36
+ ) => Promise<void>;
37
+ ptySpawner?: IControllerPtySpawner;
38
+ resolveShell?: () => Promise<IControllerTerminalShell>;
39
+ cleanupTimeoutMs?: number;
40
+ }
41
+
42
+ interface IProjectAdmissionState {
43
+ activeOperations: number;
44
+ retiring: boolean;
45
+ retired: boolean;
46
+ drainPromise?: Promise<void>;
47
+ resolveDrain?: () => void;
48
+ retirementPromise?: Promise<boolean>;
49
+ }
50
+
51
+ interface IOutputSlab {
52
+ buffer: Buffer;
53
+ start: number;
54
+ end: number;
55
+ }
56
+
57
+ interface ITerminalOutputStore {
58
+ slabs: IOutputSlab[];
59
+ start: number;
60
+ end: number;
61
+ bytes: number;
62
+ }
63
+
64
+ interface ITerminalPeerState {
65
+ peerId: string;
66
+ generation: number;
67
+ cursor: number;
68
+ detaching: boolean;
69
+ finalizing: boolean;
70
+ deliveryPromise?: Promise<void>;
71
+ }
72
+
73
+ type TTerminalFinalState = 'running' | 'failed-unconfirmed' | 'exited';
74
+
75
+ interface IControllerOwnedTerminal extends Omit<IControllerTerminal, 'id'> {
76
+ id: TControllerTerminalId;
77
+ }
78
+
79
+ interface ITerminalEntry {
80
+ projectId: string;
81
+ terminal: IControllerOwnedTerminal;
82
+ execution: plugins.smartshell.IExecResultPtyStreaming;
83
+ peers: Map<string, ITerminalPeerState>;
84
+ sizesByPeer: Map<string, { rows: number; cols: number }>;
85
+ output: ITerminalOutputStore;
86
+ controlQueue: Promise<void>;
87
+ pendingControlOperations: number;
88
+ sizeRevision: number;
89
+ sizeReconciliationActive: boolean;
90
+ sizeReconciliationPromise?: Promise<void>;
91
+ closing: boolean;
92
+ finalState: TTerminalFinalState;
93
+ exitCode?: number;
94
+ finalError?: Error;
95
+ signalError?: Error;
96
+ rootExitPromise: Promise<void>;
97
+ resolveRootExit: () => void;
98
+ exitSubscription?: plugins.smartshell.IPtySubscription;
99
+ finalizePromise?: Promise<void>;
100
+ closeLifecyclePromise?: Promise<void>;
101
+ finalizationDeadlineAt?: number;
102
+ }
103
+
104
+ type TTerminalManagerState = 'new' | 'open' | 'closing' | 'closed';
105
+
106
+ const maxTerminals = 32;
107
+ const maxTerminalsPerProject = 8;
108
+ const maxScrollbackBytes = 2 * 1024 * 1024;
109
+ const maxPtyCaptureBytes = 4 * 1024 * 1024;
110
+ const maxInputBytes = 8 * 1024;
111
+ const maxOutputFrameBytes = 64 * 1024;
112
+ const maxPeersPerTerminal = 32;
113
+ const maxPendingControlOperations = 256;
114
+ const defaultCleanupTimeoutMs = 10_000;
115
+
116
+ const normalizeError = (errorArg: unknown): Error =>
117
+ errorArg instanceof Error ? errorArg : new Error(String(errorArg));
118
+
119
+ const toTerminalDto = (terminalArg: IControllerOwnedTerminal): IControllerTerminal => ({
120
+ ...terminalArg,
121
+ id: { ...terminalArg.id },
122
+ });
123
+
124
+ const appendBoundedChunk = (
125
+ slabsArg: IOutputSlab[],
126
+ currentBytesArg: number,
127
+ chunkArg: Buffer,
128
+ ): number => {
129
+ if (chunkArg.byteLength === 0) return currentBytesArg;
130
+ let sourceOffset = 0;
131
+ let currentBytes = currentBytesArg;
132
+ if (chunkArg.byteLength >= maxScrollbackBytes) {
133
+ slabsArg.splice(0, slabsArg.length);
134
+ sourceOffset = chunkArg.byteLength - maxScrollbackBytes;
135
+ currentBytes = 0;
136
+ }
137
+
138
+ while (sourceOffset < chunkArg.byteLength) {
139
+ let slab = slabsArg.at(-1);
140
+ if (!slab || slab.end >= slab.buffer.byteLength) {
141
+ slab = {
142
+ buffer: Buffer.allocUnsafe(maxOutputFrameBytes),
143
+ start: 0,
144
+ end: 0,
145
+ };
146
+ slabsArg.push(slab);
147
+ }
148
+ const copyBytes = Math.min(
149
+ chunkArg.byteLength - sourceOffset,
150
+ slab.buffer.byteLength - slab.end,
151
+ );
152
+ chunkArg.copy(slab.buffer, slab.end, sourceOffset, sourceOffset + copyBytes);
153
+ slab.end += copyBytes;
154
+ sourceOffset += copyBytes;
155
+ currentBytes += copyBytes;
156
+ }
157
+
158
+ let excess = currentBytes - maxScrollbackBytes;
159
+ while (excess > 0) {
160
+ const first = slabsArg[0];
161
+ const firstBytes = first.end - first.start;
162
+ if (firstBytes <= excess) {
163
+ slabsArg.shift();
164
+ currentBytes -= firstBytes;
165
+ excess -= firstBytes;
166
+ } else {
167
+ first.start += excess;
168
+ currentBytes -= excess;
169
+ excess = 0;
170
+ }
171
+ }
172
+ return currentBytes;
173
+ };
174
+
175
+ const appendOutput = (outputArg: ITerminalOutputStore, chunkArg: Buffer): void => {
176
+ if (chunkArg.byteLength === 0) return;
177
+ outputArg.end += chunkArg.byteLength;
178
+ outputArg.bytes = appendBoundedChunk(outputArg.slabs, outputArg.bytes, chunkArg);
179
+ outputArg.start = outputArg.end - outputArg.bytes;
180
+ };
181
+
182
+ const validateExecutable = async (
183
+ candidateArg: string,
184
+ platformArg: NodeJS.Platform,
185
+ ): Promise<string> => {
186
+ const pathApi = platformArg === 'win32' ? plugins.path.win32 : plugins.path.posix;
187
+ if (!pathApi.isAbsolute(candidateArg)) {
188
+ throw new Error('The terminal shell executable must be an absolute path.');
189
+ }
190
+ const executable = await plugins.fs.promises.realpath(candidateArg);
191
+ const stats = await plugins.fs.promises.stat(executable);
192
+ if (!stats.isFile()) throw new Error('The terminal shell executable is not a file.');
193
+ if (platformArg !== 'win32') {
194
+ await plugins.fs.promises.access(executable, plugins.fs.constants.X_OK);
195
+ }
196
+ return executable;
197
+ };
198
+
199
+ export const resolveControllerTerminalShell = async (
200
+ environmentArg: NodeJS.ProcessEnv = process.env,
201
+ platformArg: NodeJS.Platform = process.platform,
202
+ validateExecutableArg: (
203
+ candidateArg: string,
204
+ platformArg: NodeJS.Platform,
205
+ ) => Promise<string> = validateExecutable,
206
+ ): Promise<IControllerTerminalShell> => {
207
+ const environment = createSanitizedRuntimeEnvironment(environmentArg);
208
+ if (platformArg === 'win32') {
209
+ const candidates: string[] = [];
210
+ if (environmentArg.COMSPEC && plugins.path.win32.isAbsolute(environmentArg.COMSPEC)) {
211
+ candidates.push(environmentArg.COMSPEC);
212
+ }
213
+ const systemRoot = environmentArg.SystemRoot ?? environmentArg.SYSTEMROOT;
214
+ if (systemRoot && plugins.path.win32.isAbsolute(systemRoot)) {
215
+ candidates.push(plugins.path.win32.join(systemRoot, 'System32', 'cmd.exe'));
216
+ }
217
+ for (const candidate of [...new Set(candidates)]) {
218
+ try {
219
+ const executable = await validateExecutableArg(candidate, platformArg);
220
+ delete environment.SHELL;
221
+ environment.COMSPEC = executable;
222
+ return { executable, args: [], environment };
223
+ } catch {
224
+ // Try the trusted SystemRoot fallback before failing closed.
225
+ }
226
+ }
227
+ throw new Error('No trusted Windows terminal shell executable is available.');
228
+ }
229
+
230
+ const candidates = [
231
+ ...(environmentArg.SHELL && plugins.path.posix.isAbsolute(environmentArg.SHELL)
232
+ ? [environmentArg.SHELL]
233
+ : []),
234
+ '/bin/sh',
235
+ ];
236
+ for (const candidate of [...new Set(candidates)]) {
237
+ try {
238
+ const executable = await validateExecutableArg(candidate, platformArg);
239
+ delete environment.COMSPEC;
240
+ environment.SHELL = executable;
241
+ return { executable, args: [], environment };
242
+ } catch {
243
+ // Fall through to /bin/sh, then fail closed.
244
+ }
245
+ }
246
+ throw new Error('No trusted POSIX terminal shell executable is available.');
247
+ };
248
+
249
+ const waitUntil = async (promiseArg: Promise<void>, deadlineAtArg: number): Promise<void> => {
250
+ const remainingMs = deadlineAtArg - Date.now();
251
+ if (remainingMs <= 0) throw new Error('Terminal cleanup exceeded its deadline.');
252
+ let timeout: NodeJS.Timeout | undefined;
253
+ try {
254
+ await Promise.race([
255
+ promiseArg,
256
+ new Promise<never>((_resolve, reject) => {
257
+ timeout = setTimeout(() => reject(new Error('Terminal cleanup exceeded its deadline.')), remainingMs);
258
+ }),
259
+ ]);
260
+ } finally {
261
+ if (timeout) clearTimeout(timeout);
262
+ }
263
+ };
264
+
265
+ export class ControllerTerminalManager {
266
+ private readonly ptySpawner: IControllerPtySpawner;
267
+ private readonly resolveShell: () => Promise<IControllerTerminalShell>;
268
+ private readonly cleanupTimeoutMs: number;
269
+ private readonly entries = new Map<string, ITerminalEntry>();
270
+ private readonly projectStates = new Map<string, IProjectAdmissionState>();
271
+ private readonly activeRetirements = new Set<Promise<boolean>>();
272
+ private readonly pendingRemovalCleanups = new Map<Promise<void>, string>();
273
+ private state: TTerminalManagerState = 'new';
274
+ private shell?: IControllerTerminalShell;
275
+ private initPromise?: Promise<void>;
276
+ private closePromise?: Promise<void>;
277
+ private pendingCreates = 0;
278
+ private readonly pendingCreatesByProject = new Map<string, number>();
279
+ private peerGeneration = 0;
280
+
281
+ constructor(private readonly options: IControllerTerminalManagerOptions) {
282
+ this.ptySpawner = options.ptySpawner ?? new plugins.smartshell.Smartshell({
283
+ executor: 'sh',
284
+ sourceFilePaths: [],
285
+ });
286
+ this.resolveShell = options.resolveShell ?? (() => resolveControllerTerminalShell());
287
+ this.cleanupTimeoutMs = options.cleanupTimeoutMs ?? defaultCleanupTimeoutMs;
288
+ if (
289
+ !Number.isSafeInteger(this.cleanupTimeoutMs)
290
+ || this.cleanupTimeoutMs < 1
291
+ || this.cleanupTimeoutMs > 60_000
292
+ ) {
293
+ throw new Error('cleanupTimeoutMs must be an integer between 1 and 60000.');
294
+ }
295
+ }
296
+
297
+ public async init(): Promise<void> {
298
+ if (this.state === 'open') return;
299
+ if (this.initPromise) return this.initPromise;
300
+ if (this.state !== 'new') throw new Error('The terminal manager cannot be initialized now.');
301
+ const initPromise = (async () => {
302
+ const [shell] = await Promise.all([
303
+ this.resolveShell(),
304
+ this.ptySpawner.ensurePtySupport(),
305
+ ]);
306
+ if (this.state !== 'new') throw new Error('Terminal manager initialization was cancelled.');
307
+ this.shell = shell;
308
+ this.state = 'open';
309
+ })();
310
+ this.initPromise = initPromise;
311
+ try {
312
+ await initPromise;
313
+ } finally {
314
+ if (this.initPromise === initPromise) this.initPromise = undefined;
315
+ }
316
+ }
317
+
318
+ public async listTerminals(projectIdArg: string): Promise<IControllerTerminal[]> {
319
+ return this.runProjectOperation(projectIdArg, async () => {
320
+ await this.options.resolveProjectDirectory(projectIdArg);
321
+ return [...this.entries.values()]
322
+ .filter((entry) => entry.projectId === projectIdArg && entry.finalState !== 'exited')
323
+ .map((entry) => toTerminalDto(entry.terminal));
324
+ });
325
+ }
326
+
327
+ public async createTerminal(projectIdArg: string, titleArg?: string): Promise<IControllerTerminal> {
328
+ return this.runProjectOperation(projectIdArg, async () => {
329
+ this.reserveCreate(projectIdArg);
330
+ try {
331
+ const directory = await this.options.resolveProjectDirectory(projectIdArg);
332
+ const shell = this.requireShell();
333
+ const terminalNativeId = this.createTerminalId();
334
+ return this.spawnTerminal({
335
+ projectId: projectIdArg,
336
+ terminalNativeId,
337
+ title: titleArg ?? plugins.path.basename(shell.executable),
338
+ command: shell.executable,
339
+ args: [...shell.args],
340
+ cwd: directory,
341
+ environment: { ...shell.environment },
342
+ });
343
+ } finally {
344
+ this.releaseCreate(projectIdArg);
345
+ }
346
+ });
347
+ }
348
+
349
+ public async resolveTerminalResourceMetadata(
350
+ projectIdArg: string,
351
+ titleArg?: string,
352
+ ): Promise<{ title: string; terminal: NonNullable<IControllerResourceDocument['terminal']> }> {
353
+ const directory = await this.options.resolveProjectDirectory(projectIdArg);
354
+ const shell = this.requireShell();
355
+ return {
356
+ title: titleArg ?? plugins.path.basename(shell.executable),
357
+ terminal: {
358
+ command: shell.executable,
359
+ args: [...shell.args],
360
+ cwd: directory,
361
+ stoppedAt: new Date(),
362
+ },
363
+ };
364
+ }
365
+
366
+ public async startTerminalResource(resourceArg: IControllerResourceDocument): Promise<IControllerTerminal> {
367
+ if (resourceArg.kind !== 'terminal' || !resourceArg.terminal) {
368
+ throw new Error('The resource is not a terminal.');
369
+ }
370
+ const terminal = resourceArg.terminal;
371
+ return this.runProjectOperation(resourceArg.projectId, async () => {
372
+ this.reserveCreate(resourceArg.projectId);
373
+ try {
374
+ if (this.entries.has(resourceArg.id)) {
375
+ throw new Error('The terminal resource is already running.');
376
+ }
377
+ const directory = await this.options.resolveProjectDirectory(resourceArg.projectId);
378
+ if (directory !== terminal.cwd) {
379
+ throw new Error('The terminal resource project directory binding changed.');
380
+ }
381
+ const shell = this.requireShell();
382
+ return this.spawnTerminal({
383
+ projectId: resourceArg.projectId,
384
+ terminalNativeId: resourceArg.id,
385
+ title: resourceArg.title,
386
+ command: terminal.command,
387
+ args: [...terminal.args],
388
+ cwd: directory,
389
+ environment: { ...shell.environment },
390
+ });
391
+ } finally {
392
+ this.releaseCreate(resourceArg.projectId);
393
+ }
394
+ });
395
+ }
396
+
397
+ public async stopTerminalResource(projectIdArg: string, resourceIdArg: string): Promise<void> {
398
+ const entry = this.entries.get(resourceIdArg);
399
+ if (!entry || entry.projectId !== projectIdArg) return;
400
+ await this.runProjectOperation(projectIdArg, async () => {
401
+ await this.closeEntry(entry, Date.now() + this.cleanupTimeoutMs);
402
+ });
403
+ }
404
+
405
+ public isTerminalRunning(projectIdArg: string, resourceIdArg: string): boolean {
406
+ const entry = this.entries.get(resourceIdArg);
407
+ return entry?.projectId === projectIdArg && entry.finalState === 'running';
408
+ }
409
+
410
+ private async spawnTerminal(inputArg: {
411
+ projectId: string;
412
+ terminalNativeId: string;
413
+ title: string;
414
+ command: string;
415
+ args: string[];
416
+ cwd: string;
417
+ environment: NodeJS.ProcessEnv;
418
+ }): Promise<IControllerTerminal> {
419
+ let entry: ITerminalEntry | undefined;
420
+ const output: ITerminalOutputStore = {
421
+ slabs: [],
422
+ start: 0,
423
+ end: 0,
424
+ bytes: 0,
425
+ };
426
+ const execution = await this.ptySpawner.execSpawnStreamingInteractiveControlPty(
427
+ inputArg.command,
428
+ [...inputArg.args],
429
+ {
430
+ cwd: inputArg.cwd,
431
+ env: { ...inputArg.environment },
432
+ maxBuffer: maxPtyCaptureBytes,
433
+ ptyCols: 120,
434
+ ptyRows: 30,
435
+ ptyTerm: 'xterm-256color',
436
+ onData: (chunkArg) => {
437
+ const chunk = Buffer.from(chunkArg, 'utf8');
438
+ if (entry) this.appendOutput(entry, chunk);
439
+ else appendOutput(output, chunk);
440
+ },
441
+ },
442
+ );
443
+ let resolveRootExit!: () => void;
444
+ const rootExitPromise = new Promise<void>((resolve) => {
445
+ resolveRootExit = resolve;
446
+ });
447
+ const createdEntry: ITerminalEntry = {
448
+ projectId: inputArg.projectId,
449
+ terminal: {
450
+ id: { harnessId: 'controller', nativeId: inputArg.terminalNativeId },
451
+ title: inputArg.title,
452
+ command: inputArg.command,
453
+ cwd: inputArg.cwd,
454
+ },
455
+ execution,
456
+ peers: new Map(),
457
+ sizesByPeer: new Map(),
458
+ output,
459
+ controlQueue: Promise.resolve(),
460
+ pendingControlOperations: 0,
461
+ sizeRevision: 0,
462
+ sizeReconciliationActive: false,
463
+ closing: false,
464
+ finalState: 'running',
465
+ rootExitPromise,
466
+ resolveRootExit,
467
+ };
468
+ entry = createdEntry;
469
+ this.entries.set(inputArg.terminalNativeId, createdEntry);
470
+ createdEntry.exitSubscription = execution.ptyProcess.onExit(
471
+ (eventArg) => this.confirmRootExit(createdEntry, eventArg.exitCode),
472
+ );
473
+ if (createdEntry.finalState === 'exited') createdEntry.exitSubscription.dispose();
474
+ void execution.finalPromise.then(
475
+ (resultArg) => this.confirmRootExit(createdEntry, resultArg.exitCode),
476
+ (errorArg) => {
477
+ if (createdEntry.finalState === 'running') createdEntry.finalState = 'failed-unconfirmed';
478
+ createdEntry.finalError = normalizeError(errorArg);
479
+ },
480
+ );
481
+ if (createdEntry.finalState === 'exited' || this.state !== 'open') {
482
+ await this.closeEntry(createdEntry, Date.now() + this.cleanupTimeoutMs);
483
+ throw new Error('The terminal exited while it was being created.');
484
+ }
485
+ const projectState = this.projectState(inputArg.projectId);
486
+ if (projectState.retiring || projectState.retired) {
487
+ await this.closeEntry(createdEntry, Date.now() + this.cleanupTimeoutMs);
488
+ throw new Error('The project stopped accepting terminal operations.');
489
+ }
490
+ this.options.onTerminalsChanged(inputArg.projectId);
491
+ return toTerminalDto(createdEntry.terminal);
492
+ }
493
+
494
+ public async renameTerminal(
495
+ projectIdArg: string,
496
+ terminalIdArg: string,
497
+ titleArg: string,
498
+ ): Promise<IControllerTerminal> {
499
+ const entry = this.requireEntry(projectIdArg, terminalIdArg);
500
+ return this.runProjectOperation(projectIdArg, () => (
501
+ this.runEntryControl(entry, async () => {
502
+ this.assertEntryRunning(entry);
503
+ entry.terminal.title = titleArg;
504
+ this.options.onTerminalsChanged(projectIdArg);
505
+ return toTerminalDto(entry.terminal);
506
+ })
507
+ ));
508
+ }
509
+
510
+ public async renameTerminalResource(
511
+ projectIdArg: string,
512
+ terminalIdArg: string,
513
+ titleArg: string,
514
+ ): Promise<void> {
515
+ const entry = this.entries.get(terminalIdArg);
516
+ if (!entry || entry.projectId !== projectIdArg || entry.finalState === 'exited') return;
517
+ await this.runProjectOperation(projectIdArg, () => (
518
+ this.runEntryControl(entry, async () => {
519
+ this.assertEntryRunning(entry);
520
+ entry.terminal.title = titleArg;
521
+ this.options.onTerminalsChanged(projectIdArg);
522
+ })
523
+ ));
524
+ }
525
+
526
+ public async removeTerminal(projectIdArg: string, terminalIdArg: string): Promise<boolean> {
527
+ const entry = this.entries.get(terminalIdArg);
528
+ return this.runProjectOperation(projectIdArg, async () => {
529
+ if (!entry || entry.projectId !== projectIdArg) return false;
530
+ await this.closeEntry(entry, Date.now() + this.cleanupTimeoutMs);
531
+ return true;
532
+ });
533
+ }
534
+
535
+ public async attach(
536
+ peerIdArg: string,
537
+ projectIdArg: string,
538
+ terminalIdArg: string,
539
+ isPeerConnectedArg: () => boolean = () => true,
540
+ ): Promise<void> {
541
+ const entry = this.requireEntry(projectIdArg, terminalIdArg);
542
+ return this.runProjectOperation(projectIdArg, () => (
543
+ this.runEntryControl(entry, async () => {
544
+ this.assertEntryRunning(entry);
545
+ if (!isPeerConnectedArg()) throw new Error('The terminal peer disconnected before attach.');
546
+ const previous = entry.peers.get(peerIdArg);
547
+ if (previous) {
548
+ this.detachPeerState(entry, previous);
549
+ if (previous.deliveryPromise) {
550
+ await waitUntil(
551
+ previous.deliveryPromise.catch(() => undefined),
552
+ Date.now() + this.cleanupTimeoutMs,
553
+ ).catch(() => undefined);
554
+ }
555
+ await this.applyMinimumSizeUntilStable(entry);
556
+ }
557
+ if (entry.peers.size >= maxPeersPerTerminal) {
558
+ throw new Error(`At most ${maxPeersPerTerminal} peers may attach to one terminal.`);
559
+ }
560
+ const peer: ITerminalPeerState = {
561
+ peerId: peerIdArg,
562
+ generation: ++this.peerGeneration,
563
+ cursor: entry.output.start,
564
+ detaching: false,
565
+ finalizing: false,
566
+ };
567
+ entry.peers.set(peerIdArg, peer);
568
+ try {
569
+ await this.schedulePeerDelivery(entry, peer, true);
570
+ if (!isPeerConnectedArg() || entry.peers.get(peerIdArg) !== peer) {
571
+ throw new Error('The terminal peer disconnected during attach.');
572
+ }
573
+ } catch (errorArg) {
574
+ this.detachPeerState(entry, peer);
575
+ throw errorArg;
576
+ }
577
+ })
578
+ ));
579
+ }
580
+
581
+ public async detach(peerIdArg: string, projectIdArg: string, terminalIdArg: string): Promise<void> {
582
+ const entry = this.entries.get(terminalIdArg);
583
+ return this.runProjectOperation(projectIdArg, () => {
584
+ if (!entry || entry.projectId !== projectIdArg) return Promise.resolve();
585
+ return this.runEntryControl(entry, async () => {
586
+ const peer = entry.peers.get(peerIdArg);
587
+ if (!peer) return;
588
+ this.detachPeerState(entry, peer);
589
+ if (peer.deliveryPromise) {
590
+ await waitUntil(
591
+ peer.deliveryPromise.catch(() => undefined),
592
+ Date.now() + this.cleanupTimeoutMs,
593
+ ).catch(() => undefined);
594
+ }
595
+ await this.applyMinimumSizeUntilStable(entry);
596
+ });
597
+ });
598
+ }
599
+
600
+ public async detachPeer(peerIdArg: string): Promise<void> {
601
+ const reconciliations: Promise<void>[] = [];
602
+ for (const entry of this.entries.values()) {
603
+ const peer = entry.peers.get(peerIdArg);
604
+ if (!peer) continue;
605
+ this.detachPeerState(entry, peer);
606
+ reconciliations.push(this.requestSizeReconciliation(entry));
607
+ }
608
+ await Promise.allSettled(reconciliations);
609
+ }
610
+
611
+ public async input(
612
+ peerIdArg: string,
613
+ projectIdArg: string,
614
+ terminalIdArg: string,
615
+ dataArg: Buffer,
616
+ ): Promise<void> {
617
+ if (dataArg.byteLength > maxInputBytes) {
618
+ throw new Error('Terminal input exceeds the per-message limit.');
619
+ }
620
+ const entry = this.requireEntry(projectIdArg, terminalIdArg);
621
+ return this.runProjectOperation(projectIdArg, () => (
622
+ this.runEntryControl(entry, async () => {
623
+ this.assertAttached(entry, peerIdArg);
624
+ await entry.execution.sendInput(dataArg.toString('utf8'));
625
+ })
626
+ ));
627
+ }
628
+
629
+ public async resize(
630
+ peerIdArg: string,
631
+ projectIdArg: string,
632
+ terminalIdArg: string,
633
+ rowsArg: number,
634
+ colsArg: number,
635
+ ): Promise<void> {
636
+ const entry = this.requireEntry(projectIdArg, terminalIdArg);
637
+ return this.runProjectOperation(projectIdArg, () => (
638
+ this.runEntryControl(entry, async () => {
639
+ this.assertAttached(entry, peerIdArg);
640
+ entry.sizesByPeer.set(peerIdArg, { rows: rowsArg, cols: colsArg });
641
+ entry.sizeRevision += 1;
642
+ await this.applyMinimumSizeUntilStable(entry);
643
+ })
644
+ ));
645
+ }
646
+
647
+ public async retireProject(
648
+ projectIdArg: string,
649
+ retireArg: () => Promise<boolean>,
650
+ ): Promise<boolean> {
651
+ if (this.state !== 'open') throw new Error('The terminal manager is not accepting operations.');
652
+ const projectState = this.projectState(projectIdArg);
653
+ if (projectState.retiring || projectState.retired) {
654
+ throw new Error('The project is already leaving terminal admission.');
655
+ }
656
+ projectState.retiring = true;
657
+ const deadlineAt = Date.now() + this.cleanupTimeoutMs;
658
+ const retirementPromise = (async () => {
659
+ await this.closeProjectEntries(projectIdArg, deadlineAt);
660
+ await waitUntil(this.waitForProjectDrain(projectState), deadlineAt);
661
+ await this.closeProjectEntries(projectIdArg, deadlineAt);
662
+ if ([...this.pendingRemovalCleanups.values()].includes(projectIdArg)) {
663
+ await waitUntil(this.waitForRemovalCleanups(projectIdArg), deadlineAt);
664
+ }
665
+ const retired = await retireArg();
666
+ projectState.retired = retired;
667
+ return retired;
668
+ })();
669
+ projectState.retirementPromise = retirementPromise;
670
+ this.activeRetirements.add(retirementPromise);
671
+ try {
672
+ return await retirementPromise;
673
+ } finally {
674
+ this.activeRetirements.delete(retirementPromise);
675
+ if (projectState.retirementPromise === retirementPromise) {
676
+ projectState.retirementPromise = undefined;
677
+ }
678
+ projectState.retiring = false;
679
+ this.pruneProjectState(projectIdArg, projectState);
680
+ }
681
+ }
682
+
683
+ public async closeAll(): Promise<void> {
684
+ if (this.state === 'closed') return;
685
+ if (this.closePromise) return this.closePromise;
686
+ this.state = 'closing';
687
+ const deadlineAt = Date.now() + this.cleanupTimeoutMs;
688
+ const closePromise = (async () => {
689
+ const initialClosures = [...this.entries.values()]
690
+ .map((entry) => this.closeEntry(entry, deadlineAt));
691
+ const coordination = Promise.all([
692
+ Promise.allSettled(initialClosures).then(() => undefined),
693
+ this.waitForAllProjectDrains(),
694
+ Promise.allSettled([...this.activeRetirements]).then(() => undefined),
695
+ ]).then(() => this.waitForRemovalCleanups());
696
+ let coordinationError: unknown;
697
+ try {
698
+ await waitUntil(coordination, deadlineAt);
699
+ } catch (errorArg) {
700
+ coordinationError = errorArg;
701
+ }
702
+ const finalOutcomes = await Promise.allSettled(
703
+ [...this.entries.values()].map((entry) => this.closeEntry(entry, deadlineAt)),
704
+ );
705
+ let cleanupError: unknown;
706
+ if (this.pendingRemovalCleanups.size > 0) {
707
+ try {
708
+ await waitUntil(this.waitForRemovalCleanups(), deadlineAt);
709
+ } catch (errorArg) {
710
+ cleanupError = errorArg;
711
+ if (this.entries.size === 0) this.pendingRemovalCleanups.clear();
712
+ }
713
+ }
714
+ const activeOperations = [...this.projectStates.values()]
715
+ .reduce((sum, projectState) => sum + projectState.activeOperations, 0);
716
+ const errors: unknown[] = [];
717
+ if (this.entries.size > 0) {
718
+ errors.push(new Error('One or more terminal roots remain unconfirmed.'));
719
+ }
720
+ if (activeOperations > 0) {
721
+ errors.push(new Error('One or more terminal operations remain active.'));
722
+ }
723
+ if (this.activeRetirements.size > 0) {
724
+ errors.push(new Error('One or more project retirements remain active.'));
725
+ }
726
+ if (this.pendingRemovalCleanups.size > 0) {
727
+ errors.push(new Error('One or more terminal layout cleanups remain active.'));
728
+ }
729
+ if (errors.length > 0 && coordinationError) errors.push(coordinationError);
730
+ if (errors.length > 0 && cleanupError) errors.push(cleanupError);
731
+ if (this.entries.size > 0) {
732
+ errors.push(...finalOutcomes.flatMap((outcome) => (
733
+ outcome.status === 'rejected' ? [outcome.reason] : []
734
+ )));
735
+ }
736
+ if (errors.length > 0) {
737
+ throw new AggregateError(errors, 'The terminal manager did not close every PTY.');
738
+ }
739
+ this.state = 'closed';
740
+ })();
741
+ this.closePromise = closePromise;
742
+ try {
743
+ await closePromise;
744
+ } finally {
745
+ if (this.closePromise === closePromise) this.closePromise = undefined;
746
+ }
747
+ }
748
+
749
+ private projectState(projectIdArg: string): IProjectAdmissionState {
750
+ let state = this.projectStates.get(projectIdArg);
751
+ if (!state) {
752
+ const newState: IProjectAdmissionState = {
753
+ activeOperations: 0,
754
+ retiring: false,
755
+ retired: false,
756
+ };
757
+ this.projectStates.set(projectIdArg, newState);
758
+ state = newState;
759
+ }
760
+ return state;
761
+ }
762
+
763
+ private pruneProjectState(projectIdArg: string, projectStateArg: IProjectAdmissionState): void {
764
+ if (
765
+ this.projectStates.get(projectIdArg) === projectStateArg
766
+ && projectStateArg.activeOperations === 0
767
+ && !projectStateArg.retiring
768
+ && !projectStateArg.retirementPromise
769
+ ) {
770
+ this.projectStates.delete(projectIdArg);
771
+ }
772
+ }
773
+
774
+ private async runProjectOperation<T>(
775
+ projectIdArg: string,
776
+ operationArg: () => Promise<T>,
777
+ ): Promise<T> {
778
+ if (this.state !== 'open') throw new Error('The terminal manager is not accepting operations.');
779
+ const projectState = this.projectState(projectIdArg);
780
+ if (projectState.retiring || projectState.retired) {
781
+ throw new Error('The project is not accepting terminal operations.');
782
+ }
783
+ if (projectState.activeOperations === 0) {
784
+ let resolveDrain!: () => void;
785
+ projectState.drainPromise = new Promise<void>((resolve) => {
786
+ resolveDrain = resolve;
787
+ });
788
+ projectState.resolveDrain = resolveDrain;
789
+ }
790
+ projectState.activeOperations += 1;
791
+ try {
792
+ return await operationArg();
793
+ } finally {
794
+ projectState.activeOperations -= 1;
795
+ if (projectState.activeOperations === 0) {
796
+ projectState.resolveDrain?.();
797
+ projectState.resolveDrain = undefined;
798
+ projectState.drainPromise = undefined;
799
+ this.pruneProjectState(projectIdArg, projectState);
800
+ }
801
+ }
802
+ }
803
+
804
+ private waitForProjectDrain(projectStateArg: IProjectAdmissionState): Promise<void> {
805
+ return projectStateArg.drainPromise ?? Promise.resolve();
806
+ }
807
+
808
+ private async waitForAllProjectDrains(): Promise<void> {
809
+ await Promise.all([...this.projectStates.values()].map((state) => this.waitForProjectDrain(state)));
810
+ }
811
+
812
+ private async waitForRemovalCleanups(projectIdArg?: string): Promise<void> {
813
+ while (true) {
814
+ const pending = [...this.pendingRemovalCleanups.entries()]
815
+ .filter(([, projectId]) => projectIdArg === undefined || projectId === projectIdArg)
816
+ .map(([cleanup]) => cleanup);
817
+ if (pending.length === 0) return;
818
+ await Promise.all(pending);
819
+ }
820
+ }
821
+
822
+ private reserveCreate(projectIdArg: string): void {
823
+ const activeForProject = [...this.entries.values()]
824
+ .filter((entry) => entry.projectId === projectIdArg && entry.finalState !== 'exited').length;
825
+ const pendingForProject = this.pendingCreatesByProject.get(projectIdArg) ?? 0;
826
+ if (this.entries.size + this.pendingCreates >= maxTerminals) {
827
+ throw new Error(`At most ${maxTerminals} controller terminals may run concurrently.`);
828
+ }
829
+ if (activeForProject + pendingForProject >= maxTerminalsPerProject) {
830
+ throw new Error(`At most ${maxTerminalsPerProject} terminals may run in one project.`);
831
+ }
832
+ this.pendingCreates += 1;
833
+ this.pendingCreatesByProject.set(projectIdArg, pendingForProject + 1);
834
+ }
835
+
836
+ private releaseCreate(projectIdArg: string): void {
837
+ this.pendingCreates -= 1;
838
+ const remaining = (this.pendingCreatesByProject.get(projectIdArg) ?? 1) - 1;
839
+ if (remaining <= 0) this.pendingCreatesByProject.delete(projectIdArg);
840
+ else this.pendingCreatesByProject.set(projectIdArg, remaining);
841
+ }
842
+
843
+ private createTerminalId(): string {
844
+ for (let attempt = 0; attempt < 8; attempt += 1) {
845
+ const id = `terminal_${plugins.crypto.randomBytes(16).toString('base64url')}`;
846
+ if (!this.entries.has(id)) return id;
847
+ }
848
+ throw new Error('Unable to allocate a unique terminal identifier.');
849
+ }
850
+
851
+ private requireShell(): IControllerTerminalShell {
852
+ if (!this.shell || this.state !== 'open') throw new Error('The terminal manager is not initialized.');
853
+ return this.shell;
854
+ }
855
+
856
+ private requireEntry(projectIdArg: string, terminalIdArg: string): ITerminalEntry {
857
+ const entry = this.entries.get(terminalIdArg);
858
+ if (!entry || entry.projectId !== projectIdArg || entry.finalState === 'exited') {
859
+ throw new Error('The requested terminal does not exist in this project.');
860
+ }
861
+ return entry;
862
+ }
863
+
864
+ private assertEntryRunning(entryArg: ITerminalEntry): void {
865
+ if (entryArg.closing || entryArg.finalState !== 'running') {
866
+ throw new Error('The terminal is not accepting operations.');
867
+ }
868
+ }
869
+
870
+ private assertAttached(entryArg: ITerminalEntry, peerIdArg: string): ITerminalPeerState {
871
+ this.assertEntryRunning(entryArg);
872
+ const peer = entryArg.peers.get(peerIdArg);
873
+ if (!peer || peer.detaching) throw new Error('The terminal is not attached by this peer.');
874
+ return peer;
875
+ }
876
+
877
+ private runEntryControl<T>(
878
+ entryArg: ITerminalEntry,
879
+ operationArg: () => Promise<T>,
880
+ internalArg = false,
881
+ ): Promise<T> {
882
+ if (!internalArg && entryArg.pendingControlOperations >= maxPendingControlOperations) {
883
+ return Promise.reject(new Error('The terminal control queue is full.'));
884
+ }
885
+ if (!internalArg) entryArg.pendingControlOperations += 1;
886
+ const operation = entryArg.controlQueue.then(async () => {
887
+ if (entryArg.closing || entryArg.finalState !== 'running') {
888
+ throw new Error('The terminal is not accepting operations.');
889
+ }
890
+ return operationArg();
891
+ });
892
+ entryArg.controlQueue = operation.then(() => undefined, () => undefined);
893
+ if (internalArg) return operation;
894
+ return operation.finally(() => {
895
+ entryArg.pendingControlOperations -= 1;
896
+ });
897
+ }
898
+
899
+ private appendOutput(entryArg: ITerminalEntry, chunkArg: Buffer): void {
900
+ if (chunkArg.byteLength === 0 || entryArg.finalState === 'exited') return;
901
+ appendOutput(entryArg.output, chunkArg);
902
+ if (entryArg.closing) return;
903
+ for (const peer of entryArg.peers.values()) {
904
+ if (!peer.deliveryPromise) void this.schedulePeerDelivery(entryArg, peer);
905
+ }
906
+ }
907
+
908
+ private schedulePeerDelivery(
909
+ entryArg: ITerminalEntry,
910
+ peerArg: ITerminalPeerState,
911
+ forceResetArg = false,
912
+ ): Promise<void> {
913
+ if (peerArg.detaching || peerArg.finalizing || entryArg.peers.get(peerArg.peerId) !== peerArg) {
914
+ return Promise.resolve();
915
+ }
916
+ if (peerArg.deliveryPromise) return peerArg.deliveryPromise;
917
+ const targetEnd = entryArg.output.end;
918
+ const needsReset = forceResetArg || peerArg.cursor < entryArg.output.start;
919
+ const start = needsReset ? entryArg.output.start : Math.min(peerArg.cursor, targetEnd);
920
+ const delivery = this.deliverRange(entryArg, peerArg, start, targetEnd, needsReset);
921
+ peerArg.deliveryPromise = delivery;
922
+ void delivery.catch(() => {
923
+ if (entryArg.peers.get(peerArg.peerId) === peerArg) {
924
+ this.detachPeerState(entryArg, peerArg);
925
+ void this.requestSizeReconciliation(entryArg);
926
+ }
927
+ }).finally(() => {
928
+ if (peerArg.deliveryPromise === delivery) peerArg.deliveryPromise = undefined;
929
+ if (
930
+ !peerArg.detaching
931
+ && !peerArg.finalizing
932
+ && entryArg.peers.get(peerArg.peerId) === peerArg
933
+ && peerArg.cursor < entryArg.output.end
934
+ ) {
935
+ void this.schedulePeerDelivery(entryArg, peerArg);
936
+ }
937
+ });
938
+ return delivery;
939
+ }
940
+
941
+ private async deliverRange(
942
+ entryArg: ITerminalEntry,
943
+ peerArg: ITerminalPeerState,
944
+ startArg: number,
945
+ endArg: number,
946
+ resetArg: boolean,
947
+ deadlineAtArg?: number,
948
+ ): Promise<void> {
949
+ let cursor = startArg;
950
+ let resetPending = resetArg;
951
+ let sentFrame = false;
952
+ while (cursor < endArg) {
953
+ if (peerArg.detaching || entryArg.peers.get(peerArg.peerId) !== peerArg) return;
954
+ if (cursor < entryArg.output.start) {
955
+ cursor = entryArg.output.start;
956
+ peerArg.cursor = cursor;
957
+ resetPending = true;
958
+ }
959
+ if (cursor >= endArg) break;
960
+ const frame = this.readOutputFrame(entryArg, cursor, endArg);
961
+ if (!frame || frame.byteLength === 0) break;
962
+ const send = this.sendPeerOutput(entryArg, peerArg, frame, resetPending);
963
+ if (deadlineAtArg === undefined) await send;
964
+ else await waitUntil(send, deadlineAtArg);
965
+ if (peerArg.detaching || entryArg.peers.get(peerArg.peerId) !== peerArg) return;
966
+ cursor += frame.byteLength;
967
+ peerArg.cursor = cursor;
968
+ resetPending = false;
969
+ sentFrame = true;
970
+ }
971
+ if (resetPending && !sentFrame) {
972
+ const send = this.sendPeerOutput(entryArg, peerArg, Buffer.alloc(0), true);
973
+ if (deadlineAtArg === undefined) await send;
974
+ else await waitUntil(send, deadlineAtArg);
975
+ peerArg.cursor = Math.max(peerArg.cursor, entryArg.output.start);
976
+ }
977
+ }
978
+
979
+ private readOutputFrame(
980
+ entryArg: ITerminalEntry,
981
+ startArg: number,
982
+ endArg: number,
983
+ ): Buffer | undefined {
984
+ if (startArg >= endArg) return undefined;
985
+ let slabStart = entryArg.output.start;
986
+ for (const slab of entryArg.output.slabs) {
987
+ const slabBytes = slab.end - slab.start;
988
+ const slabEnd = slabStart + slabBytes;
989
+ if (slabEnd <= startArg) {
990
+ slabStart = slabEnd;
991
+ continue;
992
+ }
993
+ if (slabStart >= endArg) break;
994
+ const offset = Math.max(0, startArg - slabStart);
995
+ const frameBytes = Math.min(
996
+ maxOutputFrameBytes,
997
+ slabBytes - offset,
998
+ endArg - startArg,
999
+ );
1000
+ return slab.buffer.subarray(slab.start + offset, slab.start + offset + frameBytes);
1001
+ }
1002
+ return undefined;
1003
+ }
1004
+
1005
+ private async sendPeerOutput(
1006
+ entryArg: ITerminalEntry,
1007
+ peerArg: ITerminalPeerState,
1008
+ dataArg: Buffer,
1009
+ resetArg: boolean,
1010
+ ): Promise<void> {
1011
+ if (peerArg.detaching || entryArg.peers.get(peerArg.peerId) !== peerArg) return;
1012
+ await this.options.sendOutput(peerArg.peerId, {
1013
+ terminalId: { ...entryArg.terminal.id },
1014
+ dataBase64: dataArg.toString('base64'),
1015
+ ...(resetArg ? { reset: true } : {}),
1016
+ });
1017
+ }
1018
+
1019
+ private detachPeerState(entryArg: ITerminalEntry, peerArg: ITerminalPeerState): void {
1020
+ peerArg.detaching = true;
1021
+ if (entryArg.peers.get(peerArg.peerId) === peerArg) {
1022
+ entryArg.peers.delete(peerArg.peerId);
1023
+ if (entryArg.sizesByPeer.delete(peerArg.peerId)) entryArg.sizeRevision += 1;
1024
+ }
1025
+ }
1026
+
1027
+ private async applyMinimumSize(entryArg: ITerminalEntry): Promise<void> {
1028
+ if (entryArg.sizesByPeer.size === 0 || entryArg.closing) return;
1029
+ let rows = Number.POSITIVE_INFINITY;
1030
+ let cols = Number.POSITIVE_INFINITY;
1031
+ for (const size of entryArg.sizesByPeer.values()) {
1032
+ rows = Math.min(rows, size.rows);
1033
+ cols = Math.min(cols, size.cols);
1034
+ }
1035
+ if (Number.isFinite(rows) && Number.isFinite(cols)) {
1036
+ await entryArg.execution.resize(cols, rows);
1037
+ }
1038
+ }
1039
+
1040
+ private async applyMinimumSizeUntilStable(entryArg: ITerminalEntry): Promise<void> {
1041
+ if (entryArg.sizeReconciliationActive || entryArg.closing) return;
1042
+ entryArg.sizeReconciliationActive = true;
1043
+ try {
1044
+ let appliedRevision: number;
1045
+ do {
1046
+ appliedRevision = entryArg.sizeRevision;
1047
+ await this.applyMinimumSize(entryArg);
1048
+ } while (!entryArg.closing && appliedRevision !== entryArg.sizeRevision);
1049
+ } finally {
1050
+ entryArg.sizeReconciliationActive = false;
1051
+ }
1052
+ }
1053
+
1054
+ private requestSizeReconciliation(entryArg: ITerminalEntry): Promise<void> {
1055
+ if (entryArg.closing || entryArg.sizeReconciliationActive) return Promise.resolve();
1056
+ if (entryArg.sizeReconciliationPromise) return entryArg.sizeReconciliationPromise;
1057
+ const reconciliation = this.runEntryControl(
1058
+ entryArg,
1059
+ () => this.applyMinimumSizeUntilStable(entryArg),
1060
+ true,
1061
+ );
1062
+ entryArg.sizeReconciliationPromise = reconciliation;
1063
+ void reconciliation.catch(() => undefined).finally(() => {
1064
+ if (entryArg.sizeReconciliationPromise === reconciliation) {
1065
+ entryArg.sizeReconciliationPromise = undefined;
1066
+ }
1067
+ });
1068
+ return reconciliation;
1069
+ }
1070
+
1071
+ private confirmRootExit(entryArg: ITerminalEntry, exitCodeArg?: number): void {
1072
+ if (entryArg.finalState === 'exited') return;
1073
+ entryArg.finalState = 'exited';
1074
+ if (exitCodeArg !== undefined) entryArg.exitCode = exitCodeArg;
1075
+ entryArg.exitSubscription?.dispose();
1076
+ entryArg.resolveRootExit();
1077
+ entryArg.finalizePromise = this.finalizeExitedEntry(
1078
+ entryArg,
1079
+ entryArg.finalizationDeadlineAt ?? Date.now() + this.cleanupTimeoutMs,
1080
+ );
1081
+ }
1082
+
1083
+ private async finalizeExitedPeer(
1084
+ entryArg: ITerminalEntry,
1085
+ peerArg: ITerminalPeerState,
1086
+ finalOutputEndArg: number,
1087
+ deadlineAtArg: number,
1088
+ ): Promise<void> {
1089
+ if (peerArg.detaching || entryArg.peers.get(peerArg.peerId) !== peerArg) return;
1090
+ peerArg.finalizing = true;
1091
+ try {
1092
+ if (peerArg.deliveryPromise) {
1093
+ await waitUntil(peerArg.deliveryPromise, deadlineAtArg);
1094
+ }
1095
+ if (peerArg.detaching || entryArg.peers.get(peerArg.peerId) !== peerArg) return;
1096
+ const needsReset = peerArg.cursor < entryArg.output.start;
1097
+ await this.deliverRange(
1098
+ entryArg,
1099
+ peerArg,
1100
+ needsReset ? entryArg.output.start : peerArg.cursor,
1101
+ finalOutputEndArg,
1102
+ needsReset,
1103
+ deadlineAtArg,
1104
+ );
1105
+ if (
1106
+ peerArg.detaching
1107
+ || entryArg.peers.get(peerArg.peerId) !== peerArg
1108
+ || peerArg.cursor < finalOutputEndArg
1109
+ ) return;
1110
+ await waitUntil(this.options.sendOutput(peerArg.peerId, {
1111
+ terminalId: { ...entryArg.terminal.id },
1112
+ dataBase64: '',
1113
+ ended: true,
1114
+ }), deadlineAtArg);
1115
+ } catch {
1116
+ // A stalled peer is detached without an ended frame; output can never race ended.
1117
+ } finally {
1118
+ this.detachPeerState(entryArg, peerArg);
1119
+ }
1120
+ }
1121
+
1122
+ private async finalizeExitedEntry(
1123
+ entryArg: ITerminalEntry,
1124
+ deadlineAtArg: number,
1125
+ ): Promise<void> {
1126
+ if (this.entries.get(entryArg.terminal.id.nativeId) !== entryArg) return;
1127
+ const finalOutputEnd = entryArg.output.end;
1128
+ await Promise.allSettled([...entryArg.peers.values()].map((peer) => (
1129
+ this.finalizeExitedPeer(entryArg, peer, finalOutputEnd, deadlineAtArg)
1130
+ )));
1131
+ this.entries.delete(entryArg.terminal.id.nativeId);
1132
+ entryArg.peers.clear();
1133
+ entryArg.sizesByPeer.clear();
1134
+ const terminalId = { ...entryArg.terminal.id };
1135
+ const removalCleanup = Promise.resolve()
1136
+ .then(() => this.options.onTerminalStopped(entryArg.projectId, terminalId, entryArg.exitCode))
1137
+ .catch(() => undefined);
1138
+ this.pendingRemovalCleanups.set(removalCleanup, entryArg.projectId);
1139
+ try {
1140
+ await waitUntil(removalCleanup, deadlineAtArg);
1141
+ } catch {
1142
+ // Resource ownership is already released; startup pruning recovers a timed-out cleanup.
1143
+ } finally {
1144
+ this.pendingRemovalCleanups.delete(removalCleanup);
1145
+ }
1146
+ try {
1147
+ this.options.onTerminalsChanged(entryArg.projectId);
1148
+ } catch {
1149
+ // Root ownership is already released; a notification callback cannot restore it.
1150
+ }
1151
+ }
1152
+
1153
+ private async closeEntry(entryArg: ITerminalEntry, deadlineAtArg: number): Promise<void> {
1154
+ if (this.entries.get(entryArg.terminal.id.nativeId) !== entryArg) return;
1155
+ if (entryArg.finalState === 'exited') {
1156
+ await waitUntil(entryArg.finalizePromise ?? Promise.resolve(), deadlineAtArg);
1157
+ return;
1158
+ }
1159
+ if (!entryArg.closeLifecyclePromise) {
1160
+ entryArg.closing = true;
1161
+ entryArg.finalizationDeadlineAt = deadlineAtArg;
1162
+ const lifecycle = (async () => {
1163
+ await entryArg.rootExitPromise;
1164
+ await entryArg.finalizePromise;
1165
+ })();
1166
+ entryArg.closeLifecyclePromise = lifecycle;
1167
+ try {
1168
+ const signal = entryArg.execution.kill();
1169
+ void signal.catch((errorArg) => {
1170
+ entryArg.signalError = normalizeError(errorArg);
1171
+ });
1172
+ } catch (errorArg) {
1173
+ entryArg.signalError = normalizeError(errorArg);
1174
+ }
1175
+ }
1176
+ try {
1177
+ await waitUntil(entryArg.closeLifecyclePromise, deadlineAtArg);
1178
+ } catch (errorArg) {
1179
+ if (this.entries.get(entryArg.terminal.id.nativeId) !== entryArg) return;
1180
+ throw entryArg.signalError
1181
+ ? new AggregateError(
1182
+ [entryArg.signalError, errorArg],
1183
+ 'The terminal root could not be confirmed stopped.',
1184
+ )
1185
+ : errorArg;
1186
+ }
1187
+ }
1188
+
1189
+ private async closeProjectEntries(projectIdArg: string, deadlineAtArg: number): Promise<void> {
1190
+ const outcomes = await Promise.allSettled(
1191
+ [...this.entries.values()]
1192
+ .filter((entry) => entry.projectId === projectIdArg)
1193
+ .map((entry) => this.closeEntry(entry, deadlineAtArg)),
1194
+ );
1195
+ const errors = outcomes.flatMap((outcome) => (
1196
+ outcome.status === 'rejected' ? [outcome.reason] : []
1197
+ ));
1198
+ const remaining = [...this.entries.values()]
1199
+ .filter((entry) => entry.projectId === projectIdArg);
1200
+ if (remaining.length > 0) {
1201
+ errors.push(new Error('One or more project terminal roots remain unconfirmed.'));
1202
+ }
1203
+ if (errors.length > 0) {
1204
+ throw new AggregateError(errors, 'The project terminal roots could not all be confirmed stopped.');
1205
+ }
1206
+ }
1207
+ }