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,1280 @@
1
+ import * as plugins from './plugins.js';
2
+ import {
3
+ openCodeExpectedVersion,
4
+ openCodeUsername,
5
+ type IOpenCodeConnectionConfig,
6
+ type IOpenCodeHealth,
7
+ type IOpenCodeLogEntry,
8
+ type IOpenCodeProcessGroupController,
9
+ type IOpenCodeSupervisorOptions,
10
+ type IOpenCodeSupervisorStatus,
11
+ } from './interfaces.opencode.js';
12
+ import { createSanitizedRuntimeEnvironment } from './functions.runtimeenvironment.js';
13
+
14
+ const defaultStartupTimeoutMs = 20_000;
15
+ const defaultHealthRequestTimeoutMs = 1_500;
16
+ const defaultHealthPollIntervalMs = 150;
17
+ const defaultStopTimeoutMs = 5_000;
18
+ const defaultFinalStopTimeoutMs = 2_000;
19
+ const defaultLogEntryLimit = 200;
20
+ const defaultLogLineByteLimit = 8 * 1024;
21
+ const maxHealthResponseBytes = 32 * 1024;
22
+ const logTruncationSuffix = '… [truncated]';
23
+ const macOsLsofPath = '/usr/sbin/lsof';
24
+
25
+ interface IChildLifecycleOutcome {
26
+ type: 'exit' | 'error';
27
+ code?: number | null;
28
+ signal?: NodeJS.Signals | null;
29
+ error?: Error;
30
+ }
31
+
32
+ interface ILogAccumulator {
33
+ decoder: TextDecoder;
34
+ partial: string;
35
+ droppingUntilNewline: boolean;
36
+ }
37
+
38
+ type TStartupOperationOutcome<T> =
39
+ | { type: 'value'; value: T }
40
+ | { type: 'operationError'; error: unknown }
41
+ | { type: 'lifecycle'; outcome: IChildLifecycleOutcome }
42
+ | { type: 'abort'; reason: unknown };
43
+
44
+ class OpenCodeVersionMismatchError extends Error {}
45
+ class OpenCodeListenerOwnershipError extends Error {}
46
+
47
+ const validateIntegerOption = (
48
+ value: number | undefined,
49
+ fallback: number,
50
+ name: string,
51
+ minimum: number,
52
+ maximum: number
53
+ ): number => {
54
+ const selected = value ?? fallback;
55
+ if (!Number.isInteger(selected) || selected < minimum || selected > maximum) {
56
+ throw new Error(`${name} must be an integer between ${minimum} and ${maximum}.`);
57
+ }
58
+ return selected;
59
+ };
60
+
61
+ const waitForPauseOrLifecycle = async (
62
+ milliseconds: number,
63
+ lifecycle: Promise<IChildLifecycleOutcome>,
64
+ signal?: AbortSignal
65
+ ): Promise<IChildLifecycleOutcome | { type: 'abort'; reason: unknown } | undefined> => {
66
+ let timeout: NodeJS.Timeout | undefined;
67
+ let abortListener: (() => void) | undefined;
68
+ try {
69
+ const outcomes: Array<
70
+ Promise<IChildLifecycleOutcome | { type: 'abort'; reason: unknown } | undefined>
71
+ > = [
72
+ new Promise<undefined>((resolve) => {
73
+ timeout = setTimeout(() => resolve(undefined), milliseconds);
74
+ }),
75
+ lifecycle,
76
+ ];
77
+ if (signal) {
78
+ outcomes.push(
79
+ new Promise<{ type: 'abort'; reason: unknown }>((resolve) => {
80
+ if (signal.aborted) {
81
+ resolve({ type: 'abort', reason: signal.reason });
82
+ return;
83
+ }
84
+ abortListener = () => resolve({ type: 'abort', reason: signal.reason });
85
+ signal.addEventListener('abort', abortListener, { once: true });
86
+ })
87
+ );
88
+ }
89
+ return await Promise.race(outcomes);
90
+ } finally {
91
+ if (timeout) {
92
+ clearTimeout(timeout);
93
+ }
94
+ if (signal && abortListener) {
95
+ signal.removeEventListener('abort', abortListener);
96
+ }
97
+ }
98
+ };
99
+
100
+ const waitForStartupOperation = async <T>(
101
+ operation: Promise<T>,
102
+ lifecycle: Promise<IChildLifecycleOutcome>,
103
+ signal?: AbortSignal
104
+ ): Promise<TStartupOperationOutcome<T>> => {
105
+ let abortListener: (() => void) | undefined;
106
+ try {
107
+ const outcomes: Array<Promise<TStartupOperationOutcome<T>>> = [
108
+ operation.then(
109
+ (value) => ({ type: 'value' as const, value }),
110
+ (error: unknown) => ({ type: 'operationError' as const, error })
111
+ ),
112
+ lifecycle.then((outcome) => ({ type: 'lifecycle' as const, outcome })),
113
+ ];
114
+ if (signal) {
115
+ outcomes.push(
116
+ new Promise<{ type: 'abort'; reason: unknown }>((resolve) => {
117
+ if (signal.aborted) {
118
+ resolve({ type: 'abort', reason: signal.reason });
119
+ return;
120
+ }
121
+ abortListener = () => resolve({ type: 'abort', reason: signal.reason });
122
+ signal.addEventListener('abort', abortListener, { once: true });
123
+ })
124
+ );
125
+ }
126
+ return await Promise.race(outcomes);
127
+ } finally {
128
+ if (signal && abortListener) {
129
+ signal.removeEventListener('abort', abortListener);
130
+ }
131
+ }
132
+ };
133
+
134
+ const startupAbortError = (reason: unknown): Error =>
135
+ reason instanceof Error
136
+ ? reason
137
+ : new Error('OpenCode startup was aborted.', { cause: reason });
138
+
139
+ const waitBounded = async (promise: Promise<unknown>, timeoutMs: number): Promise<boolean> => {
140
+ let timeout: NodeJS.Timeout | undefined;
141
+ try {
142
+ return await Promise.race([
143
+ promise.then(() => true),
144
+ new Promise<false>((resolve) => {
145
+ timeout = setTimeout(() => resolve(false), timeoutMs);
146
+ }),
147
+ ]);
148
+ } finally {
149
+ if (timeout) {
150
+ clearTimeout(timeout);
151
+ }
152
+ }
153
+ };
154
+
155
+ const isMissingProcessError = (errorArg: unknown): boolean =>
156
+ typeof errorArg === 'object'
157
+ && errorArg !== null
158
+ && 'code' in errorArg
159
+ && errorArg.code === 'ESRCH';
160
+
161
+ const defaultProcessGroupController: IOpenCodeProcessGroupController = {
162
+ isAlive: (processGroupIdArg) => {
163
+ try {
164
+ process.kill(-processGroupIdArg, 0);
165
+ return true;
166
+ } catch (errorArg) {
167
+ if (isMissingProcessError(errorArg)) return false;
168
+ throw errorArg;
169
+ }
170
+ },
171
+ signal: (processGroupIdArg, signalArg) => {
172
+ try {
173
+ process.kill(-processGroupIdArg, signalArg);
174
+ } catch (errorArg) {
175
+ if (!isMissingProcessError(errorArg)) throw errorArg;
176
+ }
177
+ },
178
+ };
179
+
180
+ const waitForProcessGroupExit = async (
181
+ processGroupControllerArg: IOpenCodeProcessGroupController,
182
+ processGroupIdArg: number,
183
+ timeoutMsArg: number,
184
+ ): Promise<boolean> => {
185
+ const deadline = Date.now() + timeoutMsArg;
186
+ while (await processGroupControllerArg.isAlive(processGroupIdArg)) {
187
+ const remainingMs = deadline - Date.now();
188
+ if (remainingMs <= 0) return false;
189
+ await new Promise<void>((resolve) => {
190
+ setTimeout(resolve, Math.min(20, remainingMs));
191
+ });
192
+ }
193
+ return true;
194
+ };
195
+
196
+ const truncateLogLine = (value: string, byteLimit: number): { line: string; truncated: boolean } => {
197
+ if (Buffer.byteLength(value, 'utf8') <= byteLimit) {
198
+ return { line: value, truncated: false };
199
+ }
200
+
201
+ const suffixBytes = Buffer.byteLength(logTruncationSuffix, 'utf8');
202
+ const contentBytes = Math.max(0, byteLimit - suffixBytes);
203
+ let line = Buffer.from(value, 'utf8').subarray(0, contentBytes).toString('utf8');
204
+ line = line.replace(/\uFFFD$/u, '');
205
+ return {
206
+ line: `${line}${logTruncationSuffix}`,
207
+ truncated: true,
208
+ };
209
+ };
210
+
211
+ const readBoundedResponseText = async (response: Response): Promise<string> => {
212
+ if (!response.body) {
213
+ return '';
214
+ }
215
+
216
+ const reader = response.body.getReader();
217
+ const chunks: Uint8Array[] = [];
218
+ let byteLength = 0;
219
+ try {
220
+ while (true) {
221
+ const { done, value } = await reader.read();
222
+ if (done) {
223
+ break;
224
+ }
225
+ byteLength += value.byteLength;
226
+ if (byteLength > maxHealthResponseBytes) {
227
+ await reader.cancel('OpenCode health response exceeded its size limit.');
228
+ throw new Error('OpenCode health response exceeded its size limit.');
229
+ }
230
+ chunks.push(value);
231
+ }
232
+ } finally {
233
+ reader.releaseLock();
234
+ }
235
+
236
+ const body = new Uint8Array(byteLength);
237
+ let offset = 0;
238
+ for (const chunk of chunks) {
239
+ body.set(chunk, offset);
240
+ offset += chunk.byteLength;
241
+ }
242
+ return new TextDecoder().decode(body);
243
+ };
244
+
245
+ const createOpenCodeEnvironment = (password: string): NodeJS.ProcessEnv => {
246
+ const environment = createSanitizedRuntimeEnvironment();
247
+ environment.OPENCODE_SERVER_USERNAME = openCodeUsername;
248
+ environment.OPENCODE_SERVER_PASSWORD = password;
249
+ return environment;
250
+ };
251
+
252
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
253
+ typeof value === 'object' && value !== null && !Array.isArray(value);
254
+
255
+ const detectLinuxLibc = (): 'glibc' | 'musl' => {
256
+ const report = process.report?.getReport();
257
+ if (isRecord(report)) {
258
+ const header = isRecord(report.header) ? report.header : undefined;
259
+ if (
260
+ typeof header?.glibcVersionRuntime === 'string' &&
261
+ header.glibcVersionRuntime.length > 0
262
+ ) {
263
+ return 'glibc';
264
+ }
265
+
266
+ if (
267
+ Array.isArray(report.sharedObjects) &&
268
+ report.sharedObjects.some(
269
+ (entry) =>
270
+ typeof entry === 'string' &&
271
+ (entry.includes('/ld-musl-') || entry.includes('/libc.musl-'))
272
+ )
273
+ ) {
274
+ return 'musl';
275
+ }
276
+ }
277
+
278
+ if (plugins.fs.existsSync('/etc/alpine-release')) {
279
+ return 'musl';
280
+ }
281
+
282
+ throw new Error('Unable to determine whether this Linux runtime uses glibc or musl.');
283
+ };
284
+
285
+ const resolvePlatformPackage = (): { packageName: string; binaryName: string } => {
286
+ if (process.platform === 'linux') {
287
+ const libcSuffix = detectLinuxLibc() === 'musl' ? '-musl' : '';
288
+ if (process.arch === 'x64') {
289
+ return {
290
+ packageName: `opencode-linux-x64-baseline${libcSuffix}`,
291
+ binaryName: 'opencode',
292
+ };
293
+ }
294
+ if (process.arch === 'arm64') {
295
+ return {
296
+ packageName: `opencode-linux-arm64${libcSuffix}`,
297
+ binaryName: 'opencode',
298
+ };
299
+ }
300
+ } else if (process.platform === 'darwin') {
301
+ if (process.arch === 'x64') {
302
+ return {
303
+ packageName: 'opencode-darwin-x64-baseline',
304
+ binaryName: 'opencode',
305
+ };
306
+ }
307
+ if (process.arch === 'arm64') {
308
+ return {
309
+ packageName: 'opencode-darwin-arm64',
310
+ binaryName: 'opencode',
311
+ };
312
+ }
313
+ } else if (process.platform === 'win32') {
314
+ if (process.arch === 'x64') {
315
+ return {
316
+ packageName: 'opencode-windows-x64-baseline',
317
+ binaryName: 'opencode.exe',
318
+ };
319
+ }
320
+ if (process.arch === 'arm64') {
321
+ return {
322
+ packageName: 'opencode-windows-arm64',
323
+ binaryName: 'opencode.exe',
324
+ };
325
+ }
326
+ }
327
+
328
+ throw new Error(
329
+ `OpenCode does not provide a supported package for ${process.platform}/${process.arch}.`
330
+ );
331
+ };
332
+
333
+ const collectLinuxLoopbackListenerInodes = (port: number): Set<string> => {
334
+ const portHex = port.toString(16).toUpperCase().padStart(4, '0');
335
+ const expectedAddress = `0100007F:${portHex}`;
336
+ const inodes = new Set<string>();
337
+ const table = plugins.fs.readFileSync('/proc/net/tcp', 'utf8');
338
+ for (const line of table.split('\n').slice(1)) {
339
+ const fields = line.trim().split(/\s+/u);
340
+ if (fields.length >= 10 && fields[1] === expectedAddress && fields[3] === '0A') {
341
+ inodes.add(fields[9]);
342
+ }
343
+ }
344
+ return inodes;
345
+ };
346
+
347
+ const verifyLinuxListenerOwnership = (processId: number, port: number): boolean => {
348
+ const listenerInodes = collectLinuxLoopbackListenerInodes(port);
349
+ if (listenerInodes.size === 0) {
350
+ return false;
351
+ }
352
+
353
+ const descriptorDirectory = `/proc/${processId}/fd`;
354
+ let descriptorNames: string[];
355
+ try {
356
+ descriptorNames = plugins.fs.readdirSync(descriptorDirectory);
357
+ } catch (error) {
358
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
359
+ return false;
360
+ }
361
+ throw new Error(`Unable to inspect OpenCode process ${processId} file descriptors.`, {
362
+ cause: error,
363
+ });
364
+ }
365
+
366
+ for (const descriptorName of descriptorNames) {
367
+ let target: string;
368
+ try {
369
+ target = plugins.fs.readlinkSync(
370
+ plugins.path.join(descriptorDirectory, descriptorName)
371
+ );
372
+ } catch (error) {
373
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
374
+ continue;
375
+ }
376
+ throw new Error(`Unable to inspect OpenCode process ${processId} socket ownership.`, {
377
+ cause: error,
378
+ });
379
+ }
380
+ const match = /^socket:\[(\d+)\]$/u.exec(target);
381
+ if (match?.[1] && listenerInodes.has(match[1])) {
382
+ return true;
383
+ }
384
+ }
385
+ return false;
386
+ };
387
+
388
+ const verifyMacOsListenerOwnership = (processId: number, port: number): boolean => {
389
+ const result = plugins.childProcess.spawnSync(
390
+ macOsLsofPath,
391
+ [
392
+ '-nP',
393
+ '-a',
394
+ '-p',
395
+ String(processId),
396
+ `-iTCP@127.0.0.1:${port}`,
397
+ '-sTCP:LISTEN',
398
+ '-Fpn',
399
+ ],
400
+ {
401
+ encoding: 'utf8',
402
+ maxBuffer: 64 * 1024,
403
+ timeout: 1_500,
404
+ windowsHide: true,
405
+ }
406
+ );
407
+ if (result.error) {
408
+ throw new Error('Unable to inspect OpenCode listener ownership with lsof.', {
409
+ cause: result.error,
410
+ });
411
+ }
412
+ if (result.status === 1) {
413
+ return false;
414
+ }
415
+ if (result.status !== 0) {
416
+ throw new Error(`lsof failed while inspecting OpenCode with status ${result.status}.`);
417
+ }
418
+
419
+ const lines = result.stdout.split(/\r?\n/u);
420
+ return (
421
+ lines.includes(`p${processId}`) &&
422
+ lines.some((line) => line === `n127.0.0.1:${port}`)
423
+ );
424
+ };
425
+
426
+ const verifyWindowsListenerOwnership = (processId: number, port: number): boolean => {
427
+ const systemRoot = process.env.SystemRoot;
428
+ if (!systemRoot || !plugins.path.isAbsolute(systemRoot)) {
429
+ throw new Error('SystemRoot is unavailable for Windows listener ownership verification.');
430
+ }
431
+ const resolvedSystemRoot = plugins.fs.realpathSync.native(systemRoot);
432
+ const netstatPath = plugins.fs.realpathSync.native(
433
+ plugins.path.join(resolvedSystemRoot, 'System32', 'netstat.exe')
434
+ );
435
+ const relativeNetstatPath = plugins.path.relative(resolvedSystemRoot, netstatPath);
436
+ if (
437
+ relativeNetstatPath.startsWith(`..${plugins.path.sep}`) ||
438
+ relativeNetstatPath === '..' ||
439
+ plugins.path.isAbsolute(relativeNetstatPath)
440
+ ) {
441
+ throw new Error('The Windows netstat executable escaped SystemRoot.');
442
+ }
443
+
444
+ const result = plugins.childProcess.spawnSync(netstatPath, ['-ano', '-p', 'tcp'], {
445
+ encoding: 'utf8',
446
+ maxBuffer: 2 * 1024 * 1024,
447
+ timeout: 1_500,
448
+ windowsHide: true,
449
+ });
450
+ if (result.error) {
451
+ throw new Error('Unable to inspect OpenCode listener ownership with netstat.', {
452
+ cause: result.error,
453
+ });
454
+ }
455
+ if (result.status !== 0) {
456
+ throw new Error(`netstat failed while inspecting OpenCode with status ${result.status}.`);
457
+ }
458
+
459
+ const expectedEndpoint = `127.0.0.1:${port}`;
460
+ return result.stdout.split(/\r?\n/u).some((line) => {
461
+ const fields = line.trim().split(/\s+/u);
462
+ return (
463
+ fields.length >= 5 &&
464
+ fields[0]?.toUpperCase() === 'TCP' &&
465
+ fields[1] === expectedEndpoint &&
466
+ fields[3]?.toUpperCase() === 'LISTENING' &&
467
+ fields[4] === String(processId)
468
+ );
469
+ });
470
+ };
471
+
472
+ export const verifyOpenCodeListenerOwnership = (
473
+ processId: number,
474
+ port: number
475
+ ): boolean => {
476
+ if (!Number.isSafeInteger(processId) || processId <= 0) {
477
+ return false;
478
+ }
479
+ if (process.platform === 'linux') {
480
+ return verifyLinuxListenerOwnership(processId, port);
481
+ }
482
+ if (process.platform === 'darwin') {
483
+ return verifyMacOsListenerOwnership(processId, port);
484
+ }
485
+ if (process.platform === 'win32') {
486
+ return verifyWindowsListenerOwnership(processId, port);
487
+ }
488
+ throw new Error(`Listener ownership verification is unsupported on ${process.platform}.`);
489
+ };
490
+
491
+ export class OpenCodeSupervisor {
492
+ public readonly directory: string;
493
+ public readonly port: number;
494
+ public readonly baseUrl: string;
495
+
496
+ private readonly executablePath: string;
497
+ private readonly password: string;
498
+ private readonly startupTimeoutMs: number;
499
+ private readonly healthRequestTimeoutMs: number;
500
+ private readonly healthPollIntervalMs: number;
501
+ private readonly stopTimeoutMs: number;
502
+ private readonly finalStopTimeoutMs: number;
503
+ private readonly logEntryLimit: number;
504
+ private readonly logLineByteLimit: number;
505
+ private readonly spawnFactory: typeof plugins.childProcess.spawn;
506
+ private readonly fetchImplementation: typeof globalThis.fetch;
507
+ private readonly listenerOwnershipVerifier: NonNullable<
508
+ IOpenCodeSupervisorOptions['listenerOwnershipVerifier']
509
+ >;
510
+ private readonly processGroupController?: IOpenCodeProcessGroupController;
511
+ private readonly onChildExit?: () => void;
512
+
513
+ private state: IOpenCodeSupervisorStatus['state'] = 'stopped';
514
+ private healthy = false;
515
+ private version?: string;
516
+ private startedAt?: number;
517
+ private child?: plugins.childProcess.ChildProcess;
518
+ private childLifecycle?: Promise<IChildLifecycleOutcome>;
519
+ private ownedProcessGroupId?: number;
520
+ private orphanedProcessGroupId?: number;
521
+ private orphanReapTask?: Promise<void>;
522
+ private childExitCleanupPending = false;
523
+ private startPromise?: Promise<IOpenCodeSupervisorStatus>;
524
+ private stopPromise?: Promise<void>;
525
+ private readonly logs: IOpenCodeLogEntry[] = [];
526
+ private readonly stdoutAccumulator: ILogAccumulator = {
527
+ decoder: new TextDecoder(),
528
+ partial: '',
529
+ droppingUntilNewline: false,
530
+ };
531
+ private readonly stderrAccumulator: ILogAccumulator = {
532
+ decoder: new TextDecoder(),
533
+ partial: '',
534
+ droppingUntilNewline: false,
535
+ };
536
+
537
+ constructor(options: IOpenCodeSupervisorOptions) {
538
+ this.directory = plugins.fs.realpathSync.native(options.directory);
539
+ const directoryStat = plugins.fs.statSync(this.directory);
540
+ if (!directoryStat.isDirectory()) {
541
+ throw new Error('The configured OpenCode working directory is not a directory.');
542
+ }
543
+
544
+ this.port = validateIntegerOption(options.port, options.port, 'OpenCode port', 1, 65_535);
545
+ this.baseUrl = `http://127.0.0.1:${this.port}`;
546
+ this.startupTimeoutMs = validateIntegerOption(
547
+ options.startupTimeoutMs,
548
+ defaultStartupTimeoutMs,
549
+ 'OpenCode startup timeout',
550
+ 1,
551
+ 300_000
552
+ );
553
+ this.healthRequestTimeoutMs = validateIntegerOption(
554
+ options.healthRequestTimeoutMs,
555
+ defaultHealthRequestTimeoutMs,
556
+ 'OpenCode health request timeout',
557
+ 1,
558
+ 60_000
559
+ );
560
+ this.healthPollIntervalMs = validateIntegerOption(
561
+ options.healthPollIntervalMs,
562
+ defaultHealthPollIntervalMs,
563
+ 'OpenCode health poll interval',
564
+ 1,
565
+ 60_000
566
+ );
567
+ this.stopTimeoutMs = validateIntegerOption(
568
+ options.stopTimeoutMs,
569
+ defaultStopTimeoutMs,
570
+ 'OpenCode stop timeout',
571
+ 1,
572
+ 300_000
573
+ );
574
+ this.finalStopTimeoutMs = validateIntegerOption(
575
+ options.finalStopTimeoutMs,
576
+ defaultFinalStopTimeoutMs,
577
+ 'OpenCode final stop timeout',
578
+ 1,
579
+ 60_000
580
+ );
581
+ this.logEntryLimit = validateIntegerOption(
582
+ options.logEntryLimit,
583
+ defaultLogEntryLimit,
584
+ 'OpenCode log entry limit',
585
+ 1,
586
+ 10_000
587
+ );
588
+ this.logLineByteLimit = validateIntegerOption(
589
+ options.logLineByteLimit,
590
+ defaultLogLineByteLimit,
591
+ 'OpenCode log line byte limit',
592
+ 16,
593
+ 1024 * 1024
594
+ );
595
+ this.spawnFactory = options.spawnFactory ?? plugins.childProcess.spawn;
596
+ this.fetchImplementation = options.fetchImplementation ?? globalThis.fetch;
597
+ this.listenerOwnershipVerifier =
598
+ options.listenerOwnershipVerifier ?? verifyOpenCodeListenerOwnership;
599
+ this.processGroupController = process.platform !== 'win32'
600
+ && (options.spawnFactory === undefined || options.processGroupController !== undefined)
601
+ ? (options.processGroupController ?? defaultProcessGroupController)
602
+ : undefined;
603
+ this.onChildExit = options.onChildExit;
604
+ this.password = plugins.crypto.randomBytes(32).toString('base64url');
605
+ this.executablePath = this.resolveExecutablePath();
606
+ }
607
+
608
+ public getStatus(): IOpenCodeSupervisorStatus {
609
+ return {
610
+ state: this.state,
611
+ healthy: this.healthy,
612
+ ...(this.child?.pid ? { pid: this.child.pid } : {}),
613
+ ...(this.version ? { version: this.version } : {}),
614
+ ...(this.startedAt ? { startedAt: this.startedAt } : {}),
615
+ };
616
+ }
617
+
618
+ public getConnectionConfig(): IOpenCodeConnectionConfig {
619
+ return {
620
+ baseUrl: this.baseUrl,
621
+ directory: this.directory,
622
+ username: openCodeUsername,
623
+ password: this.password,
624
+ };
625
+ }
626
+
627
+ public getLogSnapshot(): IOpenCodeLogEntry[] {
628
+ return this.logs.map((entry) => ({ ...entry }));
629
+ }
630
+
631
+ public async start(signal?: AbortSignal): Promise<IOpenCodeSupervisorStatus> {
632
+ if (this.state === 'ready' && this.child) {
633
+ return this.getStatus();
634
+ }
635
+ if (this.startPromise) {
636
+ return this.startPromise;
637
+ }
638
+ if (this.state === 'stopping') {
639
+ throw new Error('OpenCode cannot start while it is stopping.');
640
+ }
641
+
642
+ const startPromise = this.performStart(signal);
643
+ this.startPromise = startPromise;
644
+ try {
645
+ return await startPromise;
646
+ } finally {
647
+ if (this.startPromise === startPromise) {
648
+ this.startPromise = undefined;
649
+ }
650
+ }
651
+ }
652
+
653
+ public async checkHealth(): Promise<IOpenCodeHealth> {
654
+ try {
655
+ const health = await this.fetchHealth();
656
+ this.healthy = true;
657
+ this.version = health.version;
658
+ return health;
659
+ } catch (error) {
660
+ this.healthy = false;
661
+ throw error;
662
+ }
663
+ }
664
+
665
+ public async stop(): Promise<void> {
666
+ if (this.stopPromise) {
667
+ return this.stopPromise;
668
+ }
669
+
670
+ const child = this.child;
671
+ const lifecycle = this.childLifecycle;
672
+ if (!child || !lifecycle) {
673
+ if (this.ownedProcessGroupId !== undefined) {
674
+ throw new Error('OpenCode process-group ownership outlived child lifecycle tracking.');
675
+ }
676
+ await this.drainOrphanedProcessGroup();
677
+ this.child = undefined;
678
+ this.childLifecycle = undefined;
679
+ this.healthy = false;
680
+ this.state = 'stopped';
681
+ this.startedAt = undefined;
682
+ return;
683
+ }
684
+
685
+ const stopPromise = this.performStop(child, lifecycle);
686
+ this.stopPromise = stopPromise;
687
+ try {
688
+ await stopPromise;
689
+ } finally {
690
+ if (this.stopPromise === stopPromise) {
691
+ this.stopPromise = undefined;
692
+ }
693
+ }
694
+ }
695
+
696
+ private resolveExecutablePath(): string {
697
+ const require = plugins.createRequire(import.meta.url);
698
+ const platformPackage = resolvePlatformPackage();
699
+ const platformPackageJsonPath = plugins.fs.realpathSync.native(
700
+ require.resolve(`${platformPackage.packageName}/package.json`)
701
+ );
702
+ const installedPackageJson = JSON.parse(
703
+ plugins.fs.readFileSync(platformPackageJsonPath, 'utf8')
704
+ ) as unknown;
705
+ if (
706
+ !isRecord(installedPackageJson) ||
707
+ installedPackageJson.name !== platformPackage.packageName ||
708
+ installedPackageJson.version !== openCodeExpectedVersion
709
+ ) {
710
+ throw new Error(
711
+ `Expected ${platformPackage.packageName} ${openCodeExpectedVersion}.`
712
+ );
713
+ }
714
+
715
+ const packageDirectory = plugins.fs.realpathSync.native(
716
+ plugins.path.dirname(platformPackageJsonPath)
717
+ );
718
+ const executablePath = plugins.fs.realpathSync.native(
719
+ plugins.path.join(packageDirectory, 'bin', platformPackage.binaryName)
720
+ );
721
+ const relativeExecutablePath = plugins.path.relative(packageDirectory, executablePath);
722
+ if (
723
+ relativeExecutablePath.startsWith(`..${plugins.path.sep}`) ||
724
+ relativeExecutablePath === '..' ||
725
+ plugins.path.isAbsolute(relativeExecutablePath)
726
+ ) {
727
+ throw new Error('The package-owned OpenCode executable escaped its package directory.');
728
+ }
729
+ const executableStat = plugins.fs.statSync(executablePath);
730
+ if (
731
+ !executableStat.isFile() ||
732
+ (process.platform !== 'win32' && (executableStat.mode & 0o111) === 0)
733
+ ) {
734
+ throw new Error('The package-owned OpenCode executable is not a file.');
735
+ }
736
+ return executablePath;
737
+ }
738
+
739
+ private async performStart(signal?: AbortSignal): Promise<IOpenCodeSupervisorStatus> {
740
+ this.state = 'starting';
741
+ this.healthy = false;
742
+ this.version = undefined;
743
+ this.startedAt = undefined;
744
+
745
+ try {
746
+ signal?.throwIfAborted();
747
+ const child = this.spawnFactory(
748
+ this.executablePath,
749
+ [
750
+ 'serve',
751
+ '--hostname',
752
+ '127.0.0.1',
753
+ '--port',
754
+ String(this.port),
755
+ '--mdns=false',
756
+ '--print-logs',
757
+ ],
758
+ {
759
+ cwd: this.directory,
760
+ detached: this.processGroupController !== undefined,
761
+ shell: false,
762
+ windowsHide: true,
763
+ stdio: ['ignore', 'pipe', 'pipe'],
764
+ env: createOpenCodeEnvironment(this.password),
765
+ }
766
+ );
767
+ this.child = child;
768
+ this.childLifecycle = this.observeChild(child);
769
+
770
+ if (!child.pid) {
771
+ throw new Error('OpenCode did not provide a process ID after spawn.');
772
+ }
773
+ if (this.processGroupController) {
774
+ this.ownedProcessGroupId = child.pid;
775
+ }
776
+
777
+ const deadline = Date.now() + this.startupTimeoutMs;
778
+ let lastHealthError: unknown;
779
+ while (Date.now() < deadline) {
780
+ const lifecycle = this.childLifecycle;
781
+ if (!lifecycle) {
782
+ throw new Error('OpenCode child lifecycle tracking was lost.');
783
+ }
784
+
785
+ const ownershipOutcome = await waitForStartupOperation(
786
+ Promise.resolve().then(() => this.listenerOwnershipVerifier(child.pid!, this.port)),
787
+ lifecycle,
788
+ signal
789
+ );
790
+ if (ownershipOutcome.type === 'abort') {
791
+ throw startupAbortError(ownershipOutcome.reason);
792
+ }
793
+ if (ownershipOutcome.type === 'lifecycle') {
794
+ throw this.lifecycleError(
795
+ ownershipOutcome.outcome,
796
+ 'OpenCode exited during startup.'
797
+ );
798
+ }
799
+ if (ownershipOutcome.type === 'operationError') {
800
+ throw new OpenCodeListenerOwnershipError(
801
+ 'OpenCode listener ownership could not be verified.',
802
+ { cause: ownershipOutcome.error }
803
+ );
804
+ }
805
+ const listenerOwned = ownershipOutcome.value;
806
+
807
+ if (!listenerOwned) {
808
+ lastHealthError = new OpenCodeListenerOwnershipError(
809
+ 'The loopback listener is not owned by the spawned OpenCode process.'
810
+ );
811
+ const remainingMs = deadline - Date.now();
812
+ if (remainingMs <= 0) {
813
+ break;
814
+ }
815
+ const pauseOutcome = await waitForPauseOrLifecycle(
816
+ Math.min(this.healthPollIntervalMs, remainingMs),
817
+ lifecycle,
818
+ signal
819
+ );
820
+ if (pauseOutcome) {
821
+ if (pauseOutcome.type === 'abort') {
822
+ throw startupAbortError(pauseOutcome.reason);
823
+ }
824
+ throw this.lifecycleError(pauseOutcome, 'OpenCode exited during startup.');
825
+ }
826
+ continue;
827
+ }
828
+
829
+ const healthOutcome = await waitForStartupOperation(
830
+ this.fetchHealth(signal),
831
+ lifecycle,
832
+ signal
833
+ );
834
+
835
+ if (healthOutcome.type === 'abort') {
836
+ throw startupAbortError(healthOutcome.reason);
837
+ }
838
+ if (healthOutcome.type === 'lifecycle') {
839
+ throw this.lifecycleError(healthOutcome.outcome, 'OpenCode exited during startup.');
840
+ }
841
+ if (healthOutcome.type === 'value') {
842
+ const secondOwnershipOutcome = await waitForStartupOperation(
843
+ Promise.resolve().then(() =>
844
+ this.listenerOwnershipVerifier(child.pid!, this.port)
845
+ ),
846
+ lifecycle,
847
+ signal
848
+ );
849
+ if (secondOwnershipOutcome.type === 'abort') {
850
+ throw startupAbortError(secondOwnershipOutcome.reason);
851
+ }
852
+ if (secondOwnershipOutcome.type === 'lifecycle') {
853
+ throw this.lifecycleError(
854
+ secondOwnershipOutcome.outcome,
855
+ 'OpenCode exited during startup.'
856
+ );
857
+ }
858
+ if (secondOwnershipOutcome.type === 'operationError') {
859
+ throw new OpenCodeListenerOwnershipError(
860
+ 'OpenCode listener ownership could not be reverified.',
861
+ { cause: secondOwnershipOutcome.error }
862
+ );
863
+ }
864
+ const stillOwned = secondOwnershipOutcome.value;
865
+ if (!stillOwned) {
866
+ throw new OpenCodeListenerOwnershipError(
867
+ 'OpenCode lost ownership of its loopback listener during startup.'
868
+ );
869
+ }
870
+ if (child.exitCode !== null || child.signalCode !== null || this.child !== child) {
871
+ throw new Error('OpenCode exited while completing its health check.');
872
+ }
873
+ this.healthy = true;
874
+ this.version = healthOutcome.value.version;
875
+ this.startedAt = Date.now();
876
+ this.state = 'ready';
877
+ return this.getStatus();
878
+ }
879
+
880
+ lastHealthError = healthOutcome.error;
881
+ if (lastHealthError instanceof OpenCodeVersionMismatchError) {
882
+ throw lastHealthError;
883
+ }
884
+
885
+ const remainingMs = deadline - Date.now();
886
+ if (remainingMs <= 0) {
887
+ break;
888
+ }
889
+ const pauseOutcome = await waitForPauseOrLifecycle(
890
+ Math.min(this.healthPollIntervalMs, remainingMs),
891
+ lifecycle,
892
+ signal
893
+ );
894
+ if (pauseOutcome) {
895
+ if (pauseOutcome.type === 'abort') {
896
+ throw startupAbortError(pauseOutcome.reason);
897
+ }
898
+ throw this.lifecycleError(pauseOutcome, 'OpenCode exited during startup.');
899
+ }
900
+ }
901
+
902
+ throw new Error(`OpenCode did not become healthy within ${this.startupTimeoutMs}ms.`, {
903
+ cause: lastHealthError,
904
+ });
905
+ } catch (error) {
906
+ let cleanupError: unknown;
907
+ try {
908
+ await this.stop();
909
+ } catch (caughtCleanupError) {
910
+ cleanupError = caughtCleanupError;
911
+ }
912
+ this.state = 'failed';
913
+ this.healthy = false;
914
+ this.startedAt = undefined;
915
+
916
+ if (cleanupError) {
917
+ throw new AggregateError(
918
+ [error, cleanupError],
919
+ 'OpenCode startup failed and its child process could not be cleaned up.'
920
+ );
921
+ }
922
+ throw new Error('OpenCode startup failed.', { cause: error });
923
+ }
924
+ }
925
+
926
+ /**
927
+ * best-effort reaping of group members that survived their leader's exit.
928
+ * Runs at exit time because that is when the pgid is still provably ours:
929
+ * living members keep it reserved, and an already-empty group answers ESRCH.
930
+ */
931
+ private async reapOrphanedGroup(processGroupIdArg: number): Promise<void> {
932
+ if (!this.processGroupController) return;
933
+ if (!(await this.processGroupController.isAlive(processGroupIdArg))) return;
934
+ await this.processGroupController.signal(processGroupIdArg, 'SIGTERM');
935
+ let exited = await waitForProcessGroupExit(
936
+ this.processGroupController,
937
+ processGroupIdArg,
938
+ this.stopTimeoutMs,
939
+ );
940
+ if (!exited) {
941
+ await this.processGroupController.signal(processGroupIdArg, 'SIGKILL');
942
+ exited = await waitForProcessGroupExit(
943
+ this.processGroupController,
944
+ processGroupIdArg,
945
+ this.finalStopTimeoutMs,
946
+ );
947
+ }
948
+ if (!exited) throw new Error('An owned OpenCode process-group member survived SIGKILL.');
949
+ }
950
+
951
+ private notifyChildExitCleanup(): void {
952
+ if (!this.childExitCleanupPending) return;
953
+ this.childExitCleanupPending = false;
954
+ try {
955
+ this.onChildExit?.();
956
+ } catch {
957
+ // Child lifecycle cleanup must not prevent lifecycle settlement.
958
+ }
959
+ }
960
+
961
+ private async drainOrphanedProcessGroup(): Promise<void> {
962
+ const orphanReapTask = this.orphanReapTask;
963
+ if (orphanReapTask) await orphanReapTask;
964
+ const processGroupId = this.orphanedProcessGroupId;
965
+ if (processGroupId !== undefined) {
966
+ await this.reapOrphanedGroup(processGroupId);
967
+ if (this.orphanedProcessGroupId === processGroupId) {
968
+ this.orphanedProcessGroupId = undefined;
969
+ }
970
+ }
971
+ this.notifyChildExitCleanup();
972
+ }
973
+
974
+ private observeChild(child: plugins.childProcess.ChildProcess): Promise<IChildLifecycleOutcome> {
975
+ child.stdout?.on('data', (chunk: unknown) => {
976
+ this.consumeLogChunk('stdout', chunk, this.stdoutAccumulator);
977
+ });
978
+ child.stderr?.on('data', (chunk: unknown) => {
979
+ this.consumeLogChunk('stderr', chunk, this.stderrAccumulator);
980
+ });
981
+
982
+ return new Promise((resolve) => {
983
+ let settled = false;
984
+ const finish = (outcome: IChildLifecycleOutcome): void => {
985
+ if (settled) {
986
+ return;
987
+ }
988
+ settled = true;
989
+ this.flushLogAccumulator('stdout', this.stdoutAccumulator);
990
+ this.flushLogAccumulator('stderr', this.stderrAccumulator);
991
+ this.healthy = false;
992
+ // once the group leader is gone the kernel may recycle its pid, so a
993
+ // LATER stop() must never signal this group — but surviving group
994
+ // descendants must still be reaped NOW, while living members keep the
995
+ // pgid reserved and therefore safely targetable
996
+ const orphanedGroupId = this.ownedProcessGroupId;
997
+ this.ownedProcessGroupId = undefined;
998
+ this.childExitCleanupPending = true;
999
+ if (orphanedGroupId !== undefined && this.processGroupController) {
1000
+ this.orphanedProcessGroupId = orphanedGroupId;
1001
+ let reap!: Promise<void>;
1002
+ reap = this.reapOrphanedGroup(orphanedGroupId).then(() => {
1003
+ if (this.orphanedProcessGroupId === orphanedGroupId) {
1004
+ this.orphanedProcessGroupId = undefined;
1005
+ }
1006
+ this.notifyChildExitCleanup();
1007
+ }).finally(() => {
1008
+ if (this.orphanReapTask === reap) this.orphanReapTask = undefined;
1009
+ });
1010
+ this.orphanReapTask = reap;
1011
+ void reap.catch(() => undefined);
1012
+ } else {
1013
+ this.notifyChildExitCleanup();
1014
+ }
1015
+ if (this.state === 'stopping') {
1016
+ this.state = 'stopped';
1017
+ } else if (this.state === 'starting' || this.state === 'ready') {
1018
+ this.state = 'failed';
1019
+ }
1020
+ resolve(outcome);
1021
+ };
1022
+
1023
+ child.once('exit', (code, signal) => {
1024
+ finish({ type: 'exit', code, signal });
1025
+ });
1026
+ child.once('error', (error) => {
1027
+ this.addLogEntry('stderr', `OpenCode process error: ${error.message}`, false);
1028
+ if (!child.pid) {
1029
+ finish({ type: 'error', error });
1030
+ }
1031
+ });
1032
+ });
1033
+ }
1034
+
1035
+ private async fetchHealth(callerSignal?: AbortSignal): Promise<IOpenCodeHealth> {
1036
+ callerSignal?.throwIfAborted();
1037
+ const requestAbortController = new AbortController();
1038
+ const abortFromCaller = (): void => {
1039
+ requestAbortController.abort(callerSignal?.reason);
1040
+ };
1041
+ callerSignal?.addEventListener('abort', abortFromCaller, { once: true });
1042
+ if (callerSignal?.aborted) {
1043
+ abortFromCaller();
1044
+ }
1045
+ const timeout = setTimeout(() => {
1046
+ requestAbortController.abort(
1047
+ new Error(`OpenCode health request exceeded ${this.healthRequestTimeoutMs}ms.`)
1048
+ );
1049
+ }, this.healthRequestTimeoutMs);
1050
+ timeout.unref();
1051
+
1052
+ const authorization = Buffer.from(`${openCodeUsername}:${this.password}`, 'utf8').toString(
1053
+ 'base64'
1054
+ );
1055
+ try {
1056
+ const response = await this.fetchImplementation(`${this.baseUrl}/global/health`, {
1057
+ method: 'GET',
1058
+ headers: {
1059
+ Accept: 'application/json',
1060
+ Authorization: `Basic ${authorization}`,
1061
+ },
1062
+ redirect: 'error',
1063
+ signal: requestAbortController.signal,
1064
+ });
1065
+ if (!response.ok) {
1066
+ throw new Error(`OpenCode health check returned HTTP ${response.status}.`);
1067
+ }
1068
+
1069
+ const body = await readBoundedResponseText(response);
1070
+ let parsed: unknown;
1071
+ try {
1072
+ parsed = JSON.parse(body);
1073
+ } catch (error) {
1074
+ throw new Error('OpenCode health check returned invalid JSON.', { cause: error });
1075
+ }
1076
+ if (
1077
+ typeof parsed !== 'object' ||
1078
+ parsed === null ||
1079
+ !('healthy' in parsed) ||
1080
+ !('version' in parsed) ||
1081
+ parsed.healthy !== true ||
1082
+ typeof parsed.version !== 'string'
1083
+ ) {
1084
+ throw new Error('OpenCode health check returned an invalid payload.');
1085
+ }
1086
+ if (parsed.version !== openCodeExpectedVersion) {
1087
+ throw new OpenCodeVersionMismatchError(
1088
+ `OpenCode reported version ${parsed.version}; expected ${openCodeExpectedVersion}.`
1089
+ );
1090
+ }
1091
+
1092
+ return {
1093
+ healthy: true,
1094
+ version: openCodeExpectedVersion,
1095
+ };
1096
+ } finally {
1097
+ clearTimeout(timeout);
1098
+ callerSignal?.removeEventListener('abort', abortFromCaller);
1099
+ }
1100
+ }
1101
+
1102
+ private async performStop(
1103
+ child: plugins.childProcess.ChildProcess,
1104
+ lifecycle: Promise<IChildLifecycleOutcome>
1105
+ ): Promise<void> {
1106
+ this.state = 'stopping';
1107
+ this.healthy = false;
1108
+
1109
+ // a leader that already exited cleared ownedProcessGroupId in observeChild
1110
+ // and handed surviving members to reapOrphanedGroup, so this branch only
1111
+ // ever signals a group whose leader is still ours
1112
+ const ownedProcessGroupId = this.ownedProcessGroupId;
1113
+ if (this.processGroupController && ownedProcessGroupId !== undefined) {
1114
+ let exited = !(await this.processGroupController.isAlive(ownedProcessGroupId));
1115
+ if (!exited) {
1116
+ await this.processGroupController.signal(ownedProcessGroupId, 'SIGTERM');
1117
+ exited = await waitForProcessGroupExit(
1118
+ this.processGroupController,
1119
+ ownedProcessGroupId,
1120
+ this.stopTimeoutMs,
1121
+ );
1122
+ }
1123
+ if (!exited) {
1124
+ await this.processGroupController.signal(ownedProcessGroupId, 'SIGKILL');
1125
+ exited = await waitForProcessGroupExit(
1126
+ this.processGroupController,
1127
+ ownedProcessGroupId,
1128
+ this.finalStopTimeoutMs,
1129
+ );
1130
+ }
1131
+ if (!exited) {
1132
+ this.state = 'failed';
1133
+ throw new Error('The owned OpenCode process group did not exit after SIGKILL.');
1134
+ }
1135
+ const lifecycleSettled = await waitBounded(lifecycle, this.finalStopTimeoutMs);
1136
+ if (!lifecycleSettled) {
1137
+ this.state = 'failed';
1138
+ throw new Error('OpenCode process-group exit was not confirmed by the child lifecycle.');
1139
+ }
1140
+ this.ownedProcessGroupId = undefined;
1141
+ } else {
1142
+
1143
+ if (child.exitCode === null && child.signalCode === null) {
1144
+ try {
1145
+ child.kill('SIGTERM');
1146
+ } catch (error) {
1147
+ if (child.exitCode === null && child.signalCode === null) {
1148
+ throw new Error('Failed to send SIGTERM to OpenCode.', { cause: error });
1149
+ }
1150
+ }
1151
+ }
1152
+
1153
+ let exited = await waitBounded(lifecycle, this.stopTimeoutMs);
1154
+ if (!exited && child.exitCode === null && child.signalCode === null) {
1155
+ try {
1156
+ child.kill('SIGKILL');
1157
+ } catch (error) {
1158
+ if (child.exitCode === null && child.signalCode === null) {
1159
+ throw new Error('Failed to send SIGKILL to OpenCode.', { cause: error });
1160
+ }
1161
+ }
1162
+ exited = await waitBounded(lifecycle, this.finalStopTimeoutMs);
1163
+ }
1164
+
1165
+ if (!exited && child.exitCode === null && child.signalCode === null) {
1166
+ this.state = 'failed';
1167
+ throw new Error('OpenCode did not exit after SIGKILL.');
1168
+ }
1169
+ }
1170
+
1171
+ // a leader that exited on its own may have left a reap task running; stop()
1172
+ // must not return while owned group members are still being signalled
1173
+ await this.drainOrphanedProcessGroup();
1174
+
1175
+ if (this.child === child) {
1176
+ this.child = undefined;
1177
+ }
1178
+ if (this.childLifecycle === lifecycle) {
1179
+ this.childLifecycle = undefined;
1180
+ }
1181
+ this.state = 'stopped';
1182
+ this.healthy = false;
1183
+ this.startedAt = undefined;
1184
+ }
1185
+
1186
+ private lifecycleError(outcome: IChildLifecycleOutcome, message: string): Error {
1187
+ if (outcome.type === 'error') {
1188
+ return new Error(message, { cause: outcome.error });
1189
+ }
1190
+ const detail = outcome.signal
1191
+ ? `signal ${outcome.signal}`
1192
+ : `exit code ${String(outcome.code)}`;
1193
+ return new Error(`${message} (${detail})`);
1194
+ }
1195
+
1196
+ private consumeLogChunk(
1197
+ stream: IOpenCodeLogEntry['stream'],
1198
+ chunk: unknown,
1199
+ accumulator: ILogAccumulator
1200
+ ): void {
1201
+ let bytes: Uint8Array;
1202
+ if (typeof chunk === 'string') {
1203
+ bytes = Buffer.from(chunk, 'utf8');
1204
+ } else if (chunk instanceof Uint8Array) {
1205
+ bytes = chunk;
1206
+ } else {
1207
+ return;
1208
+ }
1209
+
1210
+ let text = accumulator.decoder.decode(bytes, { stream: true });
1211
+ while (text.length > 0) {
1212
+ if (accumulator.droppingUntilNewline) {
1213
+ const newlineIndex = text.indexOf('\n');
1214
+ if (newlineIndex === -1) {
1215
+ return;
1216
+ }
1217
+ accumulator.droppingUntilNewline = false;
1218
+ text = text.slice(newlineIndex + 1);
1219
+ continue;
1220
+ }
1221
+
1222
+ const newlineIndex = text.indexOf('\n');
1223
+ if (newlineIndex !== -1) {
1224
+ const completeLine = `${accumulator.partial}${text.slice(0, newlineIndex)}`.replace(
1225
+ /\r$/u,
1226
+ ''
1227
+ );
1228
+ accumulator.partial = '';
1229
+ const bounded = truncateLogLine(completeLine, this.logLineByteLimit);
1230
+ this.addLogEntry(stream, bounded.line, bounded.truncated);
1231
+ text = text.slice(newlineIndex + 1);
1232
+ continue;
1233
+ }
1234
+
1235
+ const combined = `${accumulator.partial}${text}`;
1236
+ if (Buffer.byteLength(combined, 'utf8') > this.logLineByteLimit) {
1237
+ const bounded = truncateLogLine(combined, this.logLineByteLimit);
1238
+ this.addLogEntry(stream, bounded.line, true);
1239
+ accumulator.partial = '';
1240
+ accumulator.droppingUntilNewline = true;
1241
+ } else {
1242
+ accumulator.partial = combined;
1243
+ }
1244
+ return;
1245
+ }
1246
+ }
1247
+
1248
+ private flushLogAccumulator(
1249
+ stream: IOpenCodeLogEntry['stream'],
1250
+ accumulator: ILogAccumulator
1251
+ ): void {
1252
+ const finalText = accumulator.decoder.decode();
1253
+ if (finalText) {
1254
+ this.consumeLogChunk(stream, finalText, accumulator);
1255
+ }
1256
+ if (accumulator.partial && !accumulator.droppingUntilNewline) {
1257
+ const bounded = truncateLogLine(accumulator.partial, this.logLineByteLimit);
1258
+ this.addLogEntry(stream, bounded.line, bounded.truncated);
1259
+ }
1260
+ accumulator.partial = '';
1261
+ accumulator.droppingUntilNewline = false;
1262
+ }
1263
+
1264
+ private addLogEntry(
1265
+ stream: IOpenCodeLogEntry['stream'],
1266
+ line: string,
1267
+ truncated: boolean
1268
+ ): void {
1269
+ this.logs.push({
1270
+ timestamp: Date.now(),
1271
+ stream,
1272
+ line,
1273
+ truncated,
1274
+ });
1275
+ const excessEntries = this.logs.length - this.logEntryLimit;
1276
+ if (excessEntries > 0) {
1277
+ this.logs.splice(0, excessEntries);
1278
+ }
1279
+ }
1280
+ }