@paperclipai/server 2026.824.0 → 2026.825.0-beta.1

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 (519) hide show
  1. package/dist/adapters/builtin-adapter-types.d.ts.map +1 -1
  2. package/dist/adapters/builtin-adapter-types.js +1 -0
  3. package/dist/adapters/builtin-adapter-types.js.map +1 -1
  4. package/dist/adapters/plugin-loader.d.ts +1 -0
  5. package/dist/adapters/plugin-loader.d.ts.map +1 -1
  6. package/dist/adapters/plugin-loader.js +12 -1
  7. package/dist/adapters/plugin-loader.js.map +1 -1
  8. package/dist/adapters/plugin-loader.test.d.ts +2 -0
  9. package/dist/adapters/plugin-loader.test.d.ts.map +1 -0
  10. package/dist/adapters/plugin-loader.test.js +37 -0
  11. package/dist/adapters/plugin-loader.test.js.map +1 -0
  12. package/dist/adapters/registry.d.ts.map +1 -1
  13. package/dist/adapters/registry.js +68 -5
  14. package/dist/adapters/registry.js.map +1 -1
  15. package/dist/adapters/registry.test.d.ts +2 -0
  16. package/dist/adapters/registry.test.d.ts.map +1 -0
  17. package/dist/adapters/registry.test.js +35 -0
  18. package/dist/adapters/registry.test.js.map +1 -0
  19. package/dist/app.d.ts.map +1 -1
  20. package/dist/app.js +5 -7
  21. package/dist/app.js.map +1 -1
  22. package/dist/auth/better-auth.d.ts +10 -0
  23. package/dist/auth/better-auth.d.ts.map +1 -1
  24. package/dist/auth/better-auth.js +47 -0
  25. package/dist/auth/better-auth.js.map +1 -1
  26. package/dist/auth/workspace-login-handoff-exchange.d.ts +75 -0
  27. package/dist/auth/workspace-login-handoff-exchange.d.ts.map +1 -0
  28. package/dist/auth/workspace-login-handoff-exchange.js +73 -0
  29. package/dist/auth/workspace-login-handoff-exchange.js.map +1 -0
  30. package/dist/auth/workspace-login-handoff-plugin.d.ts +40 -0
  31. package/dist/auth/workspace-login-handoff-plugin.d.ts.map +1 -0
  32. package/dist/auth/workspace-login-handoff-plugin.js +140 -0
  33. package/dist/auth/workspace-login-handoff-plugin.js.map +1 -0
  34. package/dist/auth/workspace-login-handoff.d.ts +189 -0
  35. package/dist/auth/workspace-login-handoff.d.ts.map +1 -0
  36. package/dist/auth/workspace-login-handoff.js +319 -0
  37. package/dist/auth/workspace-login-handoff.js.map +1 -0
  38. package/dist/build-info.json +3 -0
  39. package/dist/config.d.ts +1 -0
  40. package/dist/config.d.ts.map +1 -1
  41. package/dist/config.js +14 -0
  42. package/dist/config.js.map +1 -1
  43. package/dist/dev-runner-worktree.d.ts.map +1 -1
  44. package/dist/dev-runner-worktree.js +5 -0
  45. package/dist/dev-runner-worktree.js.map +1 -1
  46. package/dist/duplex-aggregate-ceiling-env.d.ts +21 -0
  47. package/dist/duplex-aggregate-ceiling-env.d.ts.map +1 -0
  48. package/dist/duplex-aggregate-ceiling-env.js +23 -0
  49. package/dist/duplex-aggregate-ceiling-env.js.map +1 -0
  50. package/dist/embedded-postgres-supervisor.d.ts +27 -0
  51. package/dist/embedded-postgres-supervisor.d.ts.map +1 -0
  52. package/dist/embedded-postgres-supervisor.js +72 -0
  53. package/dist/embedded-postgres-supervisor.js.map +1 -0
  54. package/dist/errors.d.ts +2 -0
  55. package/dist/errors.d.ts.map +1 -1
  56. package/dist/errors.js +6 -0
  57. package/dist/errors.js.map +1 -1
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +115 -7
  60. package/dist/index.js.map +1 -1
  61. package/dist/instrumentation.d.ts +33 -0
  62. package/dist/instrumentation.d.ts.map +1 -1
  63. package/dist/instrumentation.js +68 -1
  64. package/dist/instrumentation.js.map +1 -1
  65. package/dist/middleware/auth.d.ts.map +1 -1
  66. package/dist/middleware/auth.js +35 -10
  67. package/dist/middleware/auth.js.map +1 -1
  68. package/dist/middleware/error-handler.d.ts.map +1 -1
  69. package/dist/middleware/error-handler.js +8 -1
  70. package/dist/middleware/error-handler.js.map +1 -1
  71. package/dist/middleware/logger.d.ts.map +1 -1
  72. package/dist/middleware/logger.js +5 -2
  73. package/dist/middleware/logger.js.map +1 -1
  74. package/dist/middleware/redact-sensitive.d.ts.map +1 -1
  75. package/dist/middleware/redact-sensitive.js +4 -0
  76. package/dist/middleware/redact-sensitive.js.map +1 -1
  77. package/dist/middleware/validate.d.ts +3 -1
  78. package/dist/middleware/validate.d.ts.map +1 -1
  79. package/dist/middleware/validate.js +37 -0
  80. package/dist/middleware/validate.js.map +1 -1
  81. package/dist/routes/access.d.ts.map +1 -1
  82. package/dist/routes/access.js +20 -0
  83. package/dist/routes/access.js.map +1 -1
  84. package/dist/routes/activity.js +4 -4
  85. package/dist/routes/activity.js.map +1 -1
  86. package/dist/routes/adapter-login-route-spine.d.ts +81 -0
  87. package/dist/routes/adapter-login-route-spine.d.ts.map +1 -0
  88. package/dist/routes/adapter-login-route-spine.js +40 -0
  89. package/dist/routes/adapter-login-route-spine.js.map +1 -0
  90. package/dist/routes/adapters.d.ts +33 -0
  91. package/dist/routes/adapters.d.ts.map +1 -1
  92. package/dist/routes/adapters.js +35 -1
  93. package/dist/routes/adapters.js.map +1 -1
  94. package/dist/routes/adapters.test.d.ts +2 -0
  95. package/dist/routes/adapters.test.d.ts.map +1 -0
  96. package/dist/routes/adapters.test.js +58 -0
  97. package/dist/routes/adapters.test.js.map +1 -0
  98. package/dist/routes/agents.d.ts.map +1 -1
  99. package/dist/routes/agents.js +281 -91
  100. package/dist/routes/agents.js.map +1 -1
  101. package/dist/routes/cases.d.ts +11 -22
  102. package/dist/routes/cases.d.ts.map +1 -1
  103. package/dist/routes/cases.js +13 -13
  104. package/dist/routes/companies.js +3 -3
  105. package/dist/routes/decision-training.js +6 -6
  106. package/dist/routes/decisions.js +4 -4
  107. package/dist/routes/decisions.js.map +1 -1
  108. package/dist/routes/environments.d.ts.map +1 -1
  109. package/dist/routes/environments.js +79 -6
  110. package/dist/routes/environments.js.map +1 -1
  111. package/dist/routes/execution-workspaces.d.ts.map +1 -1
  112. package/dist/routes/execution-workspaces.js +384 -16
  113. package/dist/routes/execution-workspaces.js.map +1 -1
  114. package/dist/routes/file-resources.js +2 -2
  115. package/dist/routes/health.d.ts.map +1 -1
  116. package/dist/routes/health.js +58 -4
  117. package/dist/routes/health.js.map +1 -1
  118. package/dist/routes/instance-settings.d.ts.map +1 -1
  119. package/dist/routes/instance-settings.js +40 -0
  120. package/dist/routes/instance-settings.js.map +1 -1
  121. package/dist/routes/issues.d.ts +10 -0
  122. package/dist/routes/issues.d.ts.map +1 -1
  123. package/dist/routes/issues.js +495 -65
  124. package/dist/routes/issues.js.map +1 -1
  125. package/dist/routes/openapi.d.ts.map +1 -1
  126. package/dist/routes/openapi.js +241 -122
  127. package/dist/routes/openapi.js.map +1 -1
  128. package/dist/routes/pipelines.js +19 -19
  129. package/dist/routes/plugins.d.ts.map +1 -1
  130. package/dist/routes/plugins.js +22 -0
  131. package/dist/routes/plugins.js.map +1 -1
  132. package/dist/routes/projects.d.ts.map +1 -1
  133. package/dist/routes/projects.js +2 -0
  134. package/dist/routes/projects.js.map +1 -1
  135. package/dist/routes/routines.js +2 -2
  136. package/dist/routes/routines.js.map +1 -1
  137. package/dist/routes/secrets.d.ts +1 -1
  138. package/dist/routes/secrets.d.ts.map +1 -1
  139. package/dist/routes/secrets.js +39 -50
  140. package/dist/routes/secrets.js.map +1 -1
  141. package/dist/routes/setup-token-route.test.js +50 -3
  142. package/dist/routes/setup-token-route.test.js.map +1 -1
  143. package/dist/services/adapter-login-lease.d.ts +50 -0
  144. package/dist/services/adapter-login-lease.d.ts.map +1 -0
  145. package/dist/services/adapter-login-lease.js +26 -0
  146. package/dist/services/adapter-login-lease.js.map +1 -0
  147. package/dist/services/adapter-login-lease.test.d.ts +2 -0
  148. package/dist/services/adapter-login-lease.test.d.ts.map +1 -0
  149. package/dist/services/adapter-login-lease.test.js +68 -0
  150. package/dist/services/adapter-login-lease.test.js.map +1 -0
  151. package/dist/services/agent-action-audit.js +1 -1
  152. package/dist/services/agent-secret-bindings.d.ts +15 -0
  153. package/dist/services/agent-secret-bindings.d.ts.map +1 -1
  154. package/dist/services/agent-secret-bindings.js +2 -2
  155. package/dist/services/agent-secret-bindings.js.map +1 -1
  156. package/dist/services/agents.d.ts +13 -13
  157. package/dist/services/agents.d.ts.map +1 -1
  158. package/dist/services/agents.js +46 -5
  159. package/dist/services/agents.js.map +1 -1
  160. package/dist/services/artifact-review-documents.d.ts +34 -0
  161. package/dist/services/artifact-review-documents.d.ts.map +1 -0
  162. package/dist/services/artifact-review-documents.js +196 -0
  163. package/dist/services/artifact-review-documents.js.map +1 -0
  164. package/dist/services/assets.d.ts +1 -1
  165. package/dist/services/codex-device-login-credential-read.d.ts +58 -0
  166. package/dist/services/codex-device-login-credential-read.d.ts.map +1 -0
  167. package/dist/services/codex-device-login-credential-read.js +118 -0
  168. package/dist/services/codex-device-login-credential-read.js.map +1 -0
  169. package/dist/services/codex-device-login-reaper.d.ts.map +1 -1
  170. package/dist/services/codex-device-login-reaper.js +8 -5
  171. package/dist/services/codex-device-login-reaper.js.map +1 -1
  172. package/dist/services/codex-device-login-service.d.ts +127 -36
  173. package/dist/services/codex-device-login-service.d.ts.map +1 -1
  174. package/dist/services/codex-device-login-service.js +204 -84
  175. package/dist/services/codex-device-login-service.js.map +1 -1
  176. package/dist/services/company-portability.d.ts +3 -1
  177. package/dist/services/company-portability.d.ts.map +1 -1
  178. package/dist/services/company-portability.js +76 -20
  179. package/dist/services/company-portability.js.map +1 -1
  180. package/dist/services/company-skill-policy.d.ts +1 -1
  181. package/dist/services/costs.d.ts +1 -1
  182. package/dist/services/decisions.d.ts +6 -6
  183. package/dist/services/duplex-observability-recorder.d.ts +62 -0
  184. package/dist/services/duplex-observability-recorder.d.ts.map +1 -0
  185. package/dist/services/duplex-observability-recorder.js +54 -0
  186. package/dist/services/duplex-observability-recorder.js.map +1 -0
  187. package/dist/services/environment-config.js +5 -5
  188. package/dist/services/environment-config.js.map +1 -1
  189. package/dist/services/environment-custom-image-runtime.d.ts +116 -0
  190. package/dist/services/environment-custom-image-runtime.d.ts.map +1 -1
  191. package/dist/services/environment-custom-image-runtime.js +255 -1
  192. package/dist/services/environment-custom-image-runtime.js.map +1 -1
  193. package/dist/services/environment-custom-images.d.ts +39 -0
  194. package/dist/services/environment-custom-images.d.ts.map +1 -1
  195. package/dist/services/environment-custom-images.js +219 -17
  196. package/dist/services/environment-custom-images.js.map +1 -1
  197. package/dist/services/environment-driver-traits.d.ts +109 -0
  198. package/dist/services/environment-driver-traits.d.ts.map +1 -0
  199. package/dist/services/environment-driver-traits.js +102 -0
  200. package/dist/services/environment-driver-traits.js.map +1 -0
  201. package/dist/services/environment-execution-target.d.ts +4 -0
  202. package/dist/services/environment-execution-target.d.ts.map +1 -1
  203. package/dist/services/environment-execution-target.js +63 -7
  204. package/dist/services/environment-execution-target.js.map +1 -1
  205. package/dist/services/environment-run-orchestrator.d.ts +27 -1
  206. package/dist/services/environment-run-orchestrator.d.ts.map +1 -1
  207. package/dist/services/environment-run-orchestrator.js +24 -4
  208. package/dist/services/environment-run-orchestrator.js.map +1 -1
  209. package/dist/services/environment-runtime.d.ts +118 -20
  210. package/dist/services/environment-runtime.d.ts.map +1 -1
  211. package/dist/services/environment-runtime.js +436 -91
  212. package/dist/services/environment-runtime.js.map +1 -1
  213. package/dist/services/environments.d.ts.map +1 -1
  214. package/dist/services/environments.js +20 -2
  215. package/dist/services/environments.js.map +1 -1
  216. package/dist/services/execution-workspace-branch-ownership.d.ts +4 -0
  217. package/dist/services/execution-workspace-branch-ownership.d.ts.map +1 -0
  218. package/dist/services/execution-workspace-branch-ownership.js +9 -0
  219. package/dist/services/execution-workspace-branch-ownership.js.map +1 -0
  220. package/dist/services/execution-workspace-policy.d.ts.map +1 -1
  221. package/dist/services/execution-workspace-policy.js +3 -0
  222. package/dist/services/execution-workspace-policy.js.map +1 -1
  223. package/dist/services/execution-workspaces.d.ts +2 -0
  224. package/dist/services/execution-workspaces.d.ts.map +1 -1
  225. package/dist/services/execution-workspaces.js +90 -2
  226. package/dist/services/execution-workspaces.js.map +1 -1
  227. package/dist/services/external-objects.d.ts +19 -19
  228. package/dist/services/finance.d.ts +1 -1
  229. package/dist/services/goals.d.ts +2 -2
  230. package/dist/services/heartbeat-policy.d.ts +3 -0
  231. package/dist/services/heartbeat-policy.d.ts.map +1 -0
  232. package/dist/services/heartbeat-policy.js +10 -0
  233. package/dist/services/heartbeat-policy.js.map +1 -0
  234. package/dist/services/heartbeat-run-runtime-status.test.js +1 -1
  235. package/dist/services/heartbeat-run-runtime-status.test.js.map +1 -1
  236. package/dist/services/heartbeat.d.ts +23 -1
  237. package/dist/services/heartbeat.d.ts.map +1 -1
  238. package/dist/services/heartbeat.js +192 -11
  239. package/dist/services/heartbeat.js.map +1 -1
  240. package/dist/services/index.d.ts +1 -0
  241. package/dist/services/index.d.ts.map +1 -1
  242. package/dist/services/index.js +1 -0
  243. package/dist/services/index.js.map +1 -1
  244. package/dist/services/instance-settings.d.ts.map +1 -1
  245. package/dist/services/instance-settings.js +2 -0
  246. package/dist/services/instance-settings.js.map +1 -1
  247. package/dist/services/issue-dependency-wakeups.d.ts +57 -9
  248. package/dist/services/issue-dependency-wakeups.d.ts.map +1 -1
  249. package/dist/services/issue-dependency-wakeups.js +146 -14
  250. package/dist/services/issue-dependency-wakeups.js.map +1 -1
  251. package/dist/services/issue-dependency-wakeups.test.d.ts +2 -0
  252. package/dist/services/issue-dependency-wakeups.test.d.ts.map +1 -0
  253. package/dist/services/issue-dependency-wakeups.test.js +183 -0
  254. package/dist/services/issue-dependency-wakeups.test.js.map +1 -0
  255. package/dist/services/issue-recovery-actions.d.ts +6 -1
  256. package/dist/services/issue-recovery-actions.d.ts.map +1 -1
  257. package/dist/services/issue-recovery-actions.js +123 -44
  258. package/dist/services/issue-recovery-actions.js.map +1 -1
  259. package/dist/services/issue-thread-interactions.d.ts +8 -0
  260. package/dist/services/issue-thread-interactions.d.ts.map +1 -1
  261. package/dist/services/issue-thread-interactions.js +220 -10
  262. package/dist/services/issue-thread-interactions.js.map +1 -1
  263. package/dist/services/issues.d.ts +8 -7
  264. package/dist/services/issues.d.ts.map +1 -1
  265. package/dist/services/issues.js +84 -12
  266. package/dist/services/issues.js.map +1 -1
  267. package/dist/services/local-service-supervisor.d.ts +61 -1
  268. package/dist/services/local-service-supervisor.d.ts.map +1 -1
  269. package/dist/services/local-service-supervisor.js +221 -25
  270. package/dist/services/local-service-supervisor.js.map +1 -1
  271. package/dist/services/login-command.d.ts +46 -0
  272. package/dist/services/login-command.d.ts.map +1 -0
  273. package/dist/services/login-command.js +87 -0
  274. package/dist/services/login-command.js.map +1 -0
  275. package/dist/services/login-command.test.d.ts +2 -0
  276. package/dist/services/login-command.test.d.ts.map +1 -0
  277. package/dist/services/login-command.test.js +71 -0
  278. package/dist/services/login-command.test.js.map +1 -0
  279. package/dist/services/managed-workspace-identity.d.ts +140 -0
  280. package/dist/services/managed-workspace-identity.d.ts.map +1 -0
  281. package/dist/services/managed-workspace-identity.js +277 -0
  282. package/dist/services/managed-workspace-identity.js.map +1 -0
  283. package/dist/services/plugin-environment-driver.d.ts +15 -1
  284. package/dist/services/plugin-environment-driver.d.ts.map +1 -1
  285. package/dist/services/plugin-environment-driver.js +23 -1
  286. package/dist/services/plugin-environment-driver.js.map +1 -1
  287. package/dist/services/plugin-host-services.d.ts +3 -2
  288. package/dist/services/plugin-host-services.d.ts.map +1 -1
  289. package/dist/services/plugin-host-services.js +11 -2
  290. package/dist/services/plugin-host-services.js.map +1 -1
  291. package/dist/services/plugin-loader.d.ts.map +1 -1
  292. package/dist/services/plugin-loader.js +39 -8
  293. package/dist/services/plugin-loader.js.map +1 -1
  294. package/dist/services/plugin-managed-agents.d.ts.map +1 -1
  295. package/dist/services/plugin-managed-agents.js +50 -5
  296. package/dist/services/plugin-managed-agents.js.map +1 -1
  297. package/dist/services/plugin-manifest-validator.d.ts.map +1 -1
  298. package/dist/services/plugin-manifest-validator.js +5 -2
  299. package/dist/services/plugin-manifest-validator.js.map +1 -1
  300. package/dist/services/plugin-registry.d.ts +5 -5
  301. package/dist/services/plugin-worker-manager.d.ts +158 -11
  302. package/dist/services/plugin-worker-manager.d.ts.map +1 -1
  303. package/dist/services/plugin-worker-manager.js +1139 -79
  304. package/dist/services/plugin-worker-manager.js.map +1 -1
  305. package/dist/services/projects.d.ts +1 -1
  306. package/dist/services/recovery/disposition-repair.d.ts +24 -0
  307. package/dist/services/recovery/disposition-repair.d.ts.map +1 -0
  308. package/dist/services/recovery/disposition-repair.js +125 -0
  309. package/dist/services/recovery/disposition-repair.js.map +1 -0
  310. package/dist/services/recovery/service.d.ts +6 -6
  311. package/dist/services/recovery/service.d.ts.map +1 -1
  312. package/dist/services/recovery/service.js +856 -384
  313. package/dist/services/recovery/service.js.map +1 -1
  314. package/dist/services/recovery/stranded-notice.js +4 -4
  315. package/dist/services/recovery/stranded-notice.js.map +1 -1
  316. package/dist/services/recovery/stranded-notice.test.js +1 -1
  317. package/dist/services/recovery/stranded-notice.test.js.map +1 -1
  318. package/dist/services/recovery/successful-run-handoff.d.ts +1 -1
  319. package/dist/services/recovery/successful-run-handoff.d.ts.map +1 -1
  320. package/dist/services/recovery/successful-run-handoff.js +6 -4
  321. package/dist/services/recovery/successful-run-handoff.js.map +1 -1
  322. package/dist/services/recovery/successful-run-handoff.test.js +1 -1
  323. package/dist/services/recovery/successful-run-handoff.test.js.map +1 -1
  324. package/dist/services/recovery-observability.d.ts +1 -1
  325. package/dist/services/recovery-observability.d.ts.map +1 -1
  326. package/dist/services/recovery-observability.js +4 -1
  327. package/dist/services/recovery-observability.js.map +1 -1
  328. package/dist/services/routines.d.ts.map +1 -1
  329. package/dist/services/routines.js +86 -1
  330. package/dist/services/routines.js.map +1 -1
  331. package/dist/services/runtime-exposure/loopback-listener.js +1 -1
  332. package/dist/services/runtime-exposure/loopback-listener.js.map +1 -1
  333. package/dist/services/runtime-exposure/loopback-listener.test.js +1 -1
  334. package/dist/services/runtime-exposure/loopback-listener.test.js.map +1 -1
  335. package/dist/services/scripts/codex-auth-read.cjs +93 -0
  336. package/dist/services/secret-proposal-authorization.d.ts +15 -0
  337. package/dist/services/secret-proposal-authorization.d.ts.map +1 -0
  338. package/dist/services/secret-proposal-authorization.js +29 -0
  339. package/dist/services/secret-proposal-authorization.js.map +1 -0
  340. package/dist/services/secret-proposal-notifications.d.ts +18 -0
  341. package/dist/services/secret-proposal-notifications.d.ts.map +1 -0
  342. package/dist/services/secret-proposal-notifications.js +37 -0
  343. package/dist/services/secret-proposal-notifications.js.map +1 -0
  344. package/dist/services/secret-proposals.d.ts +20 -10
  345. package/dist/services/secret-proposals.d.ts.map +1 -1
  346. package/dist/services/secret-proposals.js +205 -14
  347. package/dist/services/secret-proposals.js.map +1 -1
  348. package/dist/services/secrets.d.ts +3 -3
  349. package/dist/services/settings-visibility.d.ts +17 -0
  350. package/dist/services/settings-visibility.d.ts.map +1 -0
  351. package/dist/services/settings-visibility.js +29 -0
  352. package/dist/services/settings-visibility.js.map +1 -0
  353. package/dist/services/setup-token-reaper.d.ts +36 -0
  354. package/dist/services/setup-token-reaper.d.ts.map +1 -0
  355. package/dist/services/setup-token-reaper.js +38 -0
  356. package/dist/services/setup-token-reaper.js.map +1 -0
  357. package/dist/services/setup-token-reaper.test.d.ts +2 -0
  358. package/dist/services/setup-token-reaper.test.d.ts.map +1 -0
  359. package/dist/services/setup-token-reaper.test.js +123 -0
  360. package/dist/services/setup-token-reaper.test.js.map +1 -0
  361. package/dist/services/setup-token-session.d.ts +57 -45
  362. package/dist/services/setup-token-session.d.ts.map +1 -1
  363. package/dist/services/setup-token-session.js +118 -110
  364. package/dist/services/setup-token-session.js.map +1 -1
  365. package/dist/services/setup-token-session.test.js +88 -47
  366. package/dist/services/setup-token-session.test.js.map +1 -1
  367. package/dist/services/setup-token-transport-binding.d.ts +20 -12
  368. package/dist/services/setup-token-transport-binding.d.ts.map +1 -1
  369. package/dist/services/setup-token-transport-binding.js +59 -32
  370. package/dist/services/setup-token-transport-binding.js.map +1 -1
  371. package/dist/services/setup-token-transport-binding.test.js +50 -17
  372. package/dist/services/setup-token-transport-binding.test.js.map +1 -1
  373. package/dist/services/smoke-lab.d.ts +1 -1
  374. package/dist/services/stalled-review-decisions.d.ts +1 -1
  375. package/dist/services/status-cards.d.ts +168 -168
  376. package/dist/services/tool-access-policy.d.ts +2 -2
  377. package/dist/services/tool-gateway.d.ts +1 -1
  378. package/dist/services/work-products.d.ts +19 -0
  379. package/dist/services/work-products.d.ts.map +1 -1
  380. package/dist/services/work-products.js +50 -3
  381. package/dist/services/work-products.js.map +1 -1
  382. package/dist/services/workspace-git-operation-scheduler.test.js +10 -3
  383. package/dist/services/workspace-git-operation-scheduler.test.js.map +1 -1
  384. package/dist/services/workspace-instance-cleanup.d.ts +9 -0
  385. package/dist/services/workspace-instance-cleanup.d.ts.map +1 -1
  386. package/dist/services/workspace-instance-cleanup.js +29 -3
  387. package/dist/services/workspace-instance-cleanup.js.map +1 -1
  388. package/dist/services/workspace-login-handoff-issuer.d.ts +95 -0
  389. package/dist/services/workspace-login-handoff-issuer.d.ts.map +1 -0
  390. package/dist/services/workspace-login-handoff-issuer.js +147 -0
  391. package/dist/services/workspace-login-handoff-issuer.js.map +1 -0
  392. package/dist/services/workspace-operations.d.ts +4 -1
  393. package/dist/services/workspace-operations.d.ts.map +1 -1
  394. package/dist/services/workspace-operations.js +29 -15
  395. package/dist/services/workspace-operations.js.map +1 -1
  396. package/dist/services/workspace-readiness.d.ts +56 -0
  397. package/dist/services/workspace-readiness.d.ts.map +1 -0
  398. package/dist/services/workspace-readiness.js +250 -0
  399. package/dist/services/workspace-readiness.js.map +1 -0
  400. package/dist/services/workspace-realization.d.ts.map +1 -1
  401. package/dist/services/workspace-realization.js +5 -33
  402. package/dist/services/workspace-realization.js.map +1 -1
  403. package/dist/services/workspace-runtime-exposure.test.js +321 -12
  404. package/dist/services/workspace-runtime-exposure.test.js.map +1 -1
  405. package/dist/services/workspace-runtime-leases.d.ts.map +1 -1
  406. package/dist/services/workspace-runtime-leases.js +1 -1
  407. package/dist/services/workspace-runtime-leases.js.map +1 -1
  408. package/dist/services/workspace-runtime-read-model.d.ts +8 -8
  409. package/dist/services/workspace-runtime-read-model.test.js +20 -0
  410. package/dist/services/workspace-runtime-read-model.test.js.map +1 -1
  411. package/dist/services/workspace-runtime-ready-comment.test.js +1 -0
  412. package/dist/services/workspace-runtime-ready-comment.test.js.map +1 -1
  413. package/dist/services/workspace-runtime.d.ts +77 -4
  414. package/dist/services/workspace-runtime.d.ts.map +1 -1
  415. package/dist/services/workspace-runtime.js +1020 -126
  416. package/dist/services/workspace-runtime.js.map +1 -1
  417. package/dist/worktree-config.d.ts.map +1 -1
  418. package/dist/worktree-config.js +3 -76
  419. package/dist/worktree-config.js.map +1 -1
  420. package/dist/worktree-seed-manifest.d.ts +8 -0
  421. package/dist/worktree-seed-manifest.d.ts.map +1 -0
  422. package/dist/worktree-seed-manifest.js +47 -0
  423. package/dist/worktree-seed-manifest.js.map +1 -0
  424. package/package.json +34 -30
  425. package/skills/paperclip/SKILL.md +2 -0
  426. package/skills/paperclip/references/api-reference.md +44 -2
  427. package/ui-dist/assets/CompanyExport-DnEsO-St.js +4 -0
  428. package/ui-dist/assets/{abnfDiagram-N423BO3Z-BvgoV-u5.js → abnfDiagram-N423BO3Z-2ff8TsQ0.js} +1 -1
  429. package/ui-dist/assets/{arc-BInnUBUU.js → arc-B2AXsH34.js} +1 -1
  430. package/ui-dist/assets/architectureDiagram-T3A2C74G-BDxtKMxk.js +36 -0
  431. package/ui-dist/assets/{blockDiagram-VBNYF7ZC-D9kzAkLN.js → blockDiagram-VBNYF7ZC-BB8T5Mj5.js} +1 -1
  432. package/ui-dist/assets/{c4Diagram-5PPSVZJV-BwzsgV_0.js → c4Diagram-5PPSVZJV-lMo8qk-x.js} +1 -1
  433. package/ui-dist/assets/channel-DCTVZpyq.js +1 -0
  434. package/ui-dist/assets/{chunk-2GRJ4B5K-D2EQ9vrl.js → chunk-2GRJ4B5K-CsS7EB3G.js} +1 -1
  435. package/ui-dist/assets/{chunk-2Q5K7J3B-DOA_8fSd.js → chunk-2Q5K7J3B-DFyQPjoR.js} +1 -1
  436. package/ui-dist/assets/{chunk-5RXB4S5H-CT5ukRiT.js → chunk-5RXB4S5H-No2SHOQR.js} +1 -1
  437. package/ui-dist/assets/{chunk-5VM5RSS4-D2gOlkyB.js → chunk-5VM5RSS4-DdxQO7V9.js} +1 -1
  438. package/ui-dist/assets/{chunk-6Q2QTUOP-DUb6aG8c.js → chunk-6Q2QTUOP-gbHjh-aK.js} +1 -1
  439. package/ui-dist/assets/{chunk-GF5L2VYU-BPabG3Cx.js → chunk-GF5L2VYU-h-BBl6NY.js} +1 -1
  440. package/ui-dist/assets/{chunk-JWPE2WC7-scNfhlZz.js → chunk-JWPE2WC7-Q3pJdTz5.js} +1 -1
  441. package/ui-dist/assets/{chunk-KBJHAD2P-BcRAzRpq.js → chunk-KBJHAD2P-Ba8LGLSx.js} +1 -1
  442. package/ui-dist/assets/{chunk-RYQCIY6F-BiZ0-EGh.js → chunk-RYQCIY6F-9VcvykBo.js} +1 -1
  443. package/ui-dist/assets/{chunk-XXDRQBXY-lJULxFIE.js → chunk-XXDRQBXY-NVbjDH4W.js} +1 -1
  444. package/ui-dist/assets/classDiagram-JCYQIIEL-CzJ88u6h.js +1 -0
  445. package/ui-dist/assets/classDiagram-v2-OCEON4UE-CzJ88u6h.js +1 -0
  446. package/ui-dist/assets/cose-bilkent-JH36ORCC-Cdz4wtUh.js +1 -0
  447. package/ui-dist/assets/{cynefin-VYW2F7L2-B34jJY1x.js → cynefin-VYW2F7L2-MpzJkW5s.js} +1 -1
  448. package/ui-dist/assets/{cynefinDiagram-MW4NZA55-DQ8QK8JK.js → cynefinDiagram-MW4NZA55-_Nx8av7i.js} +1 -1
  449. package/ui-dist/assets/{dagre-VZM6K2ZE-B7lSKgxH.js → dagre-VZM6K2ZE-DcZTQ4Oh.js} +1 -1
  450. package/ui-dist/assets/{diagram-7IWD3JNH-B3hLAFGE.js → diagram-7IWD3JNH-UyLi2mee.js} +1 -1
  451. package/ui-dist/assets/{diagram-B4RE2ZJO-CH9Ro9e0.js → diagram-B4RE2ZJO-B6p0Ivqg.js} +1 -1
  452. package/ui-dist/assets/{diagram-LBJQPF4R-DdPVMii7.js → diagram-LBJQPF4R-C8u6pa97.js} +1 -1
  453. package/ui-dist/assets/{diagram-Q27KOJAE-f5VXswjc.js → diagram-Q27KOJAE-DbwyagW0.js} +1 -1
  454. package/ui-dist/assets/{diagram-UB23O5K3-DGqxixsR.js → diagram-UB23O5K3-Cn5BMEH7.js} +1 -1
  455. package/ui-dist/assets/{ebnfDiagram-BXEA7PRR-C-1klg_w.js → ebnfDiagram-BXEA7PRR-vE0IZma9.js} +1 -1
  456. package/ui-dist/assets/{erDiagram-JOGREHBK-Dt4qK4gv.js → erDiagram-JOGREHBK-3OZVIlVG.js} +1 -1
  457. package/ui-dist/assets/{flowDiagram-UKHOOZJN-9ZlrTuET.js → flowDiagram-UKHOOZJN-C94FiPyr.js} +1 -1
  458. package/ui-dist/assets/ganttDiagram-PKOTCBZU-DMVaPmF-.js +292 -0
  459. package/ui-dist/assets/{gitGraphDiagram-DS77QQ5N-HH2fSgad.js → gitGraphDiagram-DS77QQ5N-yeFtJ689.js} +1 -1
  460. package/ui-dist/assets/{index-Cm6iEYPt.js → index-B4ZF3vY8.js} +288 -224
  461. package/ui-dist/assets/{index-CHXG4GPL.js → index-Bi_vqkcQ.js} +1 -1
  462. package/ui-dist/assets/{index-Bc9g6q63.js → index-BjDGQg9-.js} +1 -1
  463. package/ui-dist/assets/{index-FHzrJ7iQ.js → index-BnpyUMTb.js} +1 -1
  464. package/ui-dist/assets/{index-C1mpWCcn.js → index-BoqQTVMA.js} +1 -1
  465. package/ui-dist/assets/index-Bq-Mr-4x.js +1 -0
  466. package/ui-dist/assets/{index-DceuCrr8.js → index-Brb2U7N4.js} +1 -1
  467. package/ui-dist/assets/{index-DQIrrn2L.js → index-BsBybVwH.js} +1 -1
  468. package/ui-dist/assets/{index-CQhaM6qd.js → index-C2iv4Wgu.js} +1 -1
  469. package/ui-dist/assets/{index-DV2FOgyD.js → index-CKxIu7uO.js} +1 -1
  470. package/ui-dist/assets/{index-DU4U1fuv.js → index-Cb4VGIwp.js} +1 -1
  471. package/ui-dist/assets/{index-B61ORkIb.js → index-ChpszwcO.js} +1 -1
  472. package/ui-dist/assets/{index-DlVBBF8o.js → index-CoQFdT8N.js} +1 -1
  473. package/ui-dist/assets/index-D6P8_f0W.css +1 -0
  474. package/ui-dist/assets/index-DKdmghUG.js +1 -0
  475. package/ui-dist/assets/{index-CVTL3aAL.js → index-DaBci7Bx.js} +1 -1
  476. package/ui-dist/assets/{index-diFs0CNC.js → index-DjYaNiXZ.js} +1 -1
  477. package/ui-dist/assets/{index-Bf7tyGxE.js → index-DxTBJl4h.js} +1 -1
  478. package/ui-dist/assets/{index-DsdVneqe.js → index-Dz3E8nn0.js} +1 -1
  479. package/ui-dist/assets/{index-DvFUXI-Q.js → index-J7dz_hap.js} +1 -1
  480. package/ui-dist/assets/{index-CLu3Tfpv.js → index-JMp2Gpnz.js} +1 -1
  481. package/ui-dist/assets/{index-CcRTJ93J.js → index-Kp6wavQt.js} +1 -1
  482. package/ui-dist/assets/{index-BvFx-4KW.js → index-LBhYZ0L4.js} +1 -1
  483. package/ui-dist/assets/{index-BbAhzCq8.js → index-c3C0P6l8.js} +4 -4
  484. package/ui-dist/assets/{index-BY5ITKmG.js → index-pBEPheTW.js} +1 -1
  485. package/ui-dist/assets/{infoDiagram-6WML65LV-2jai2fw-.js → infoDiagram-6WML65LV-BiL7jKLA.js} +1 -1
  486. package/ui-dist/assets/{ishikawaDiagram-WSZJBQD7-CUmGhmEb.js → ishikawaDiagram-WSZJBQD7-DYcQy41D.js} +1 -1
  487. package/ui-dist/assets/{journeyDiagram-NVQOT4AX-CGQuPDO1.js → journeyDiagram-NVQOT4AX-Bxva6JgA.js} +1 -1
  488. package/ui-dist/assets/{kanban-definition-27J2QSJJ-zgEPcbHl.js → kanban-definition-27J2QSJJ-B2HpN34Q.js} +1 -1
  489. package/ui-dist/assets/{linear-BVf5VtLS.js → linear-Dq8qSESw.js} +1 -1
  490. package/ui-dist/assets/{mermaid.core-D2TFkGfI.js → mermaid.core-Ikay8Cpl.js} +35 -35
  491. package/ui-dist/assets/{mindmap-definition-FAOFIHXS-xoAQ0Lb_.js → mindmap-definition-FAOFIHXS-C-SIdYSY.js} +1 -1
  492. package/ui-dist/assets/{pegDiagram-VL7TDLO6-CBR1PWLr.js → pegDiagram-VL7TDLO6-C7rIJ_13.js} +1 -1
  493. package/ui-dist/assets/{pieDiagram-7S7Q4E2Y-BMPnzK_e.js → pieDiagram-7S7Q4E2Y-3gx0a0ve.js} +1 -1
  494. package/ui-dist/assets/{quadrantDiagram-CIZ2JOQS-CPtVUPkR.js → quadrantDiagram-CIZ2JOQS-BiwM9QGv.js} +1 -1
  495. package/ui-dist/assets/{railroadDiagram-AXF67PYL-DC0lsZV1.js → railroadDiagram-AXF67PYL-rFS8XcFA.js} +1 -1
  496. package/ui-dist/assets/{requirementDiagram-LRYGKXZP-BF6k05SB.js → requirementDiagram-LRYGKXZP-CEAUDUTQ.js} +1 -1
  497. package/ui-dist/assets/{sankeyDiagram-W5VNT64P-CRN0ibiP.js → sankeyDiagram-W5VNT64P-B7anpjlM.js} +1 -1
  498. package/ui-dist/assets/{sequenceDiagram-SI44F4Z6-CQtTiPWm.js → sequenceDiagram-SI44F4Z6-CJEdJvm_.js} +1 -1
  499. package/ui-dist/assets/{sizeCapture-X5ZJPWSS-BdjuGZd1.js → sizeCapture-X5ZJPWSS-CU1Vm6c1.js} +1 -1
  500. package/ui-dist/assets/{stateDiagram-OKZ733FA-B99onxyh.js → stateDiagram-OKZ733FA-wxVV9WIi.js} +1 -1
  501. package/ui-dist/assets/stateDiagram-v2-UEYNNEHI-BIFLqlqR.js +1 -0
  502. package/ui-dist/assets/{swimlanes-SLNWSIFB-BIByPbrL.js → swimlanes-SLNWSIFB-Cd-C1Z_k.js} +2 -2
  503. package/ui-dist/assets/swimlanesDiagram-ULZ7WXOC-CoBROkkd.js +8 -0
  504. package/ui-dist/assets/{timeline-definition-Z64GVDOM-I1NgOcn9.js → timeline-definition-Z64GVDOM-SWngk4aR.js} +1 -1
  505. package/ui-dist/assets/{vennDiagram-T6HMQDX7-D6t2Njcl.js → vennDiagram-T6HMQDX7-DFdPUr36.js} +1 -1
  506. package/ui-dist/assets/{wardleyDiagram-T6FBY63Y-BiYKm9gh.js → wardleyDiagram-T6FBY63Y-C6IlrZ7t.js} +1 -1
  507. package/ui-dist/assets/{xychartDiagram-ELKLHX3M-BhwXKWIa.js → xychartDiagram-ELKLHX3M-BNm6hHGl.js} +1 -1
  508. package/ui-dist/index.html +2 -2
  509. package/ui-dist/assets/architectureDiagram-T3A2C74G-Dg4MziOm.js +0 -36
  510. package/ui-dist/assets/channel-qoSYbCn0.js +0 -1
  511. package/ui-dist/assets/classDiagram-JCYQIIEL-B24u1REs.js +0 -1
  512. package/ui-dist/assets/classDiagram-v2-OCEON4UE-B24u1REs.js +0 -1
  513. package/ui-dist/assets/cose-bilkent-JH36ORCC-Do8pe8JB.js +0 -1
  514. package/ui-dist/assets/ganttDiagram-PKOTCBZU-CE3lyvFq.js +0 -292
  515. package/ui-dist/assets/index-BHiUiToL.css +0 -1
  516. package/ui-dist/assets/index-hkanj4t6.js +0 -1
  517. package/ui-dist/assets/index-yRJnh4vx.js +0 -1
  518. package/ui-dist/assets/stateDiagram-v2-UEYNNEHI-bzFlYBZ1.js +0 -1
  519. package/ui-dist/assets/swimlanesDiagram-ULZ7WXOC-DgdkBnuK.js +0 -8
@@ -21,11 +21,26 @@ import { fork } from "node:child_process";
21
21
  import { randomUUID } from "node:crypto";
22
22
  import { EventEmitter } from "node:events";
23
23
  import { createInterface } from "node:readline";
24
- import { JSONRPC_VERSION, JSONRPC_ERROR_CODES, PLUGIN_RPC_ERROR_CODES, createRequest, createErrorResponse, parseMessage, serializeMessage, isJsonRpcResponse, isJsonRpcRequest, isJsonRpcNotification, isJsonRpcSuccessResponse, JsonRpcParseError, JsonRpcCallError, SETUP_TOKEN_PTY_OUTPUT_NOTIFICATION, SETUP_TOKEN_PTY_EXIT_NOTIFICATION, } from "@paperclipai/plugin-sdk";
24
+ import { JSONRPC_VERSION, JSONRPC_ERROR_CODES, PLUGIN_RPC_ERROR_CODES, createRequest, createErrorResponse, parseMessage, serializeMessage, isJsonRpcResponse, isJsonRpcRequest, isJsonRpcNotification, isJsonRpcSuccessResponse, JsonRpcParseError, JsonRpcCallError, LOGIN_PTY_OUTPUT_NOTIFICATION, LOGIN_PTY_EXIT_NOTIFICATION, DUPLEX_CHANNEL_DATA_NOTIFICATION, DUPLEX_CHANNEL_EXIT_NOTIFICATION, encodeChannelBytes, decodeChannelBytes, } from "@paperclipai/plugin-sdk";
25
25
  import { getActiveStepContext } from "@paperclipai/adapter-utils/acpx-engine/startup-timing";
26
- import { CLAUDE_SETUP_TOKEN_COMMAND } from "@paperclipai/adapter-claude-local/server";
26
+ import { DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED, } from "@paperclipai/adapter-utils/duplex-aggregate-byte-ledger";
27
+ import { isLoginCommandKey, validateLoginSessionHome, } from "./login-command.js";
27
28
  import { logger } from "../middleware/logger.js";
28
29
  import { traceparentFromContextToken } from "../instrumentation.js";
30
+ /**
31
+ * The host raises this error when the child-stdin transport reservation for a
32
+ * duplex write fails against the aggregate byte ledger. The host does not write
33
+ * the frame. The write path throws it, `callInternal` rejects the RPC with it
34
+ * unwrapped, and the duplex write caller ends the route fail-closed with the
35
+ * {@link DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED} marker. Only a duplex write
36
+ * meters the transport, so this error never reaches a non-duplex control message.
37
+ */
38
+ class DuplexAggregateBytesExceededError extends Error {
39
+ constructor() {
40
+ super(DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED);
41
+ this.name = "DuplexAggregateBytesExceededError";
42
+ }
43
+ }
29
44
  // ---------------------------------------------------------------------------
30
45
  // Constants
31
46
  // ---------------------------------------------------------------------------
@@ -88,24 +103,119 @@ const MAX_WORKER_MESSAGE_CHARS = 128 * 1024 * 1024;
88
103
  */
89
104
  const MAX_EXECUTE_LOG_TOTAL_CHARS = 128 * 1024 * 1024;
90
105
  /** Maximum characters for one live login pseudo-terminal output notification. */
91
- const MAX_SETUP_TOKEN_PTY_CHUNK_CHARS = 1_000_000;
106
+ const MAX_LOGIN_PTY_CHUNK_CHARS = 1_000_000;
92
107
  /** Maximum cumulative output characters for one login pseudo-terminal route. */
93
- const MAX_SETUP_TOKEN_PTY_TOTAL_CHARS = 8 * 1024 * 1024;
108
+ const MAX_LOGIN_PTY_TOTAL_CHARS = 8 * 1024 * 1024;
94
109
  /** The default open timeout for one login pseudo-terminal route, in milliseconds. */
95
- const SETUP_TOKEN_PTY_OPEN_TIMEOUT_MS = 30_000;
110
+ const LOGIN_PTY_OPEN_TIMEOUT_MS = 30_000;
96
111
  /** The default close timeout for one login pseudo-terminal route, in milliseconds. */
97
- const SETUP_TOKEN_PTY_CLOSE_TIMEOUT_MS = 10_000;
112
+ const LOGIN_PTY_CLOSE_TIMEOUT_MS = 10_000;
98
113
  /**
99
- * The fixed non-secret error a disallowed login command returns. The manager
100
- * forwards only the compile-time `CLAUDE_SETUP_TOKEN_COMMAND` to the worker
101
- * pseudo-terminal. It rejects any other command before the worker call, so a
102
- * future caller cannot spawn an arbitrary process in the sandbox.
114
+ * The fixed non-secret error a disallowed login command key returns. The manager
115
+ * forwards only a closed login command key to the worker pseudo-terminal. It
116
+ * rejects a key outside the closed set before the worker call, so a caller cannot
117
+ * select an arbitrary command in the sandbox.
103
118
  */
104
- const SETUP_TOKEN_PTY_COMMAND_NOT_ALLOWED = "SETUP_TOKEN_PTY_COMMAND_NOT_ALLOWED";
119
+ const LOGIN_PTY_COMMAND_NOT_ALLOWED = "LOGIN_PTY_COMMAND_NOT_ALLOWED";
105
120
  /** The fixed non-secret error a rejected second credential open returns. */
106
- const SETUP_TOKEN_PTY_ROUTE_BUSY = "SETUP_TOKEN_PTY_ROUTE_BUSY";
121
+ const LOGIN_PTY_ROUTE_BUSY = "LOGIN_PTY_ROUTE_BUSY";
107
122
  /** The fixed non-secret error a failed open returns. */
108
- const SETUP_TOKEN_PTY_OPEN_FAILED = "SETUP_TOKEN_PTY_OPEN_FAILED";
123
+ const LOGIN_PTY_OPEN_FAILED = "LOGIN_PTY_OPEN_FAILED";
124
+ // Bounds and timeouts for the generic duplex channel route. The route mirrors the
125
+ // login pseudo-terminal route, but it carries no command allowlist and adds seven
126
+ // explicit bounds the pseudo-terminal route lacks. Each bound ends the route when
127
+ // it passes the limit, so a faulty or hostile worker cannot flood the host.
128
+ /** The default maximum characters for one duplex channel data notification. */
129
+ const MAX_DUPLEX_CHANNEL_CHUNK_CHARS = 1_000_000;
130
+ /**
131
+ * The default maximum cumulative characters the host buffers for one duplex
132
+ * channel route before a data listener attaches. A worker that streams data
133
+ * before the consumer binds cannot grow the host buffer without limit.
134
+ */
135
+ const MAX_DUPLEX_CHANNEL_PRE_BIND_CHARS = 8 * 1024 * 1024;
136
+ /**
137
+ * The default maximum number of data frames the host buffers for one duplex
138
+ * channel route before a data listener attaches.
139
+ */
140
+ const MAX_DUPLEX_CHANNEL_PRE_BIND_FRAMES = 10_000;
141
+ /**
142
+ * The margin the pre-bind hold ceiling keeps above the pre-bind buffered-frame
143
+ * bound (`maxDuplexChannelPreBindFrames`). A worker can batch data and exit
144
+ * frames with the open reply, so the host reads them before the route binds and
145
+ * before it can apply the per-frame bounds. The host holds these frames and
146
+ * replays them after the bind. The hold ceiling bounds that hold, so a worker
147
+ * that floods frames before the open reply cannot make the host hold an
148
+ * unbounded number of frames.
149
+ *
150
+ * The hold ceiling derives from the buffered bound, not a fixed constant. A
151
+ * fixed ceiling at or below a caller-configured buffered bound would drop the
152
+ * frame that must instead trip the buffered bound during replay, so the route
153
+ * would never end. One frame of margin is enough. It lets the frame that
154
+ * exceeds the buffered bound reach the hold, so the replay's buffered-bound
155
+ * check, not the hold, ends the route.
156
+ */
157
+ const DUPLEX_CHANNEL_PRE_BIND_HOLD_MARGIN_FRAMES = 1;
158
+ /**
159
+ * The default maximum number of in-flight host→worker requests for one duplex
160
+ * channel route. A worker that never replies cannot make the host hold an
161
+ * unbounded number of pending requests.
162
+ */
163
+ const MAX_DUPLEX_CHANNEL_PENDING_REQUESTS = 256;
164
+ /** The default maximum characters for one host→worker duplex channel write. */
165
+ const MAX_DUPLEX_CHANNEL_WRITE_CHARS = 1_000_000;
166
+ /**
167
+ * The default maximum number of protocol errors for one duplex channel route.
168
+ * A protocol error is one malformed or mismatched data frame. The route ends
169
+ * when the count passes this budget, so a flood of bad frames bounds the route.
170
+ */
171
+ const MAX_DUPLEX_CHANNEL_PROTOCOL_ERRORS = 100;
172
+ /**
173
+ * The default maximum cumulative bytes the host forwards for one duplex channel
174
+ * route over its whole life. The host counts the bytes of every inbound chunk,
175
+ * before and after a data listener attaches. The route ends when the count
176
+ * passes this cap, so an active route with a bound listener cannot stream an
177
+ * unbounded number of bytes.
178
+ */
179
+ const MAX_DUPLEX_CHANNEL_TOTAL_DATA_BYTES = 256 * 1024 * 1024;
180
+ /**
181
+ * The default maximum lifetime for one duplex channel route, in milliseconds.
182
+ * The host starts a timer when the route opens and ends the route when the
183
+ * timer expires, so a route cannot live without limit.
184
+ */
185
+ const MAX_DUPLEX_CHANNEL_DURATION_MS = 60 * 60 * 1000;
186
+ /** The default open timeout for one duplex channel route, in milliseconds. */
187
+ const DUPLEX_CHANNEL_OPEN_TIMEOUT_MS = 30_000;
188
+ /** The default close timeout for one duplex channel route, in milliseconds. */
189
+ const DUPLEX_CHANNEL_CLOSE_TIMEOUT_MS = 10_000;
190
+ /** The fixed non-secret error a rejected second duplex channel open returns. */
191
+ const DUPLEX_CHANNEL_ROUTE_BUSY = "DUPLEX_CHANNEL_ROUTE_BUSY";
192
+ /** The fixed non-secret error a failed duplex channel open returns. */
193
+ const DUPLEX_CHANNEL_OPEN_FAILED = "DUPLEX_CHANNEL_OPEN_FAILED";
194
+ // The process-scoped monotonic route-generation source. The host mints one
195
+ // strictly increasing, non-reusable `hostRouteId` on each duplex channel open,
196
+ // across every worker in the process. A retired generation never returns, so a
197
+ // late frame for a closed pair never collides with a new open. The host owns the
198
+ // value; no worker field sets it.
199
+ let duplexHostRouteIdSequence = 0;
200
+ /** Mint the next monotonic, non-reusable host route identifier for a duplex channel open. */
201
+ function nextDuplexHostRouteId() {
202
+ duplexHostRouteIdSequence += 1;
203
+ return `duplex-route-${duplexHostRouteIdSequence}`;
204
+ }
205
+ // The separator between the two identifiers of a duplex route pair key. It is a
206
+ // null byte, which appears in neither a host route id nor a worker session id, so
207
+ // two distinct pairs never collapse to one key.
208
+ const DUPLEX_PAIR_KEY_SEPARATOR = "\u0000";
209
+ /** Build the exact-pair routing key from the host route id and the worker session id. */
210
+ function duplexPairKey(hostRouteId, workerSessionId) {
211
+ return `${hostRouteId}${DUPLEX_PAIR_KEY_SEPARATOR}${workerSessionId}`;
212
+ }
213
+ // The maximum number of tombstoned duplex pairs one worker retains. The worker
214
+ // keeps every tombstone until it retires, so a closed pair never returns. Before
215
+ // the set would exceed this bound, the host retires the worker, which drops every
216
+ // route and every tombstone at once. A tombstone overflow fails closed; the host
217
+ // never evicts a tombstone and lets a pair return.
218
+ const MAX_DUPLEX_ROUTE_TOMBSTONES = 4096;
109
219
  /** Minimum time between two dropped-`execute.log` debug records. The router
110
220
  * rate-limits the record so a flood of dropped chunks writes at most one line
111
221
  * per window with a running count. */
@@ -190,10 +300,34 @@ export function createPluginWorkerHandle(pluginId, options) {
190
300
  const maxExecuteLogTotalChars = options.executeLogLimits?.maxTotalCharsPerExecute ?? MAX_EXECUTE_LOG_TOTAL_CHARS;
191
301
  // Bounds and timeouts for the login pseudo-terminal route. A caller
192
302
  // (a test) can lower them to exercise the terminalize paths.
193
- const maxSetupTokenPtyChunkChars = options.setupTokenPtyLimits?.maxChunkChars ?? MAX_SETUP_TOKEN_PTY_CHUNK_CHARS;
194
- const maxSetupTokenPtyTotalChars = options.setupTokenPtyLimits?.maxTotalChars ?? MAX_SETUP_TOKEN_PTY_TOTAL_CHARS;
195
- const setupTokenPtyOpenTimeoutMs = options.setupTokenPtyLimits?.openTimeoutMs ?? SETUP_TOKEN_PTY_OPEN_TIMEOUT_MS;
196
- const setupTokenPtyCloseTimeoutMs = options.setupTokenPtyLimits?.closeTimeoutMs ?? SETUP_TOKEN_PTY_CLOSE_TIMEOUT_MS;
303
+ const maxLoginPtyChunkChars = options.loginPtyLimits?.maxChunkChars ?? MAX_LOGIN_PTY_CHUNK_CHARS;
304
+ const maxLoginPtyTotalChars = options.loginPtyLimits?.maxTotalChars ?? MAX_LOGIN_PTY_TOTAL_CHARS;
305
+ const loginPtyOpenTimeoutMs = options.loginPtyLimits?.openTimeoutMs ?? LOGIN_PTY_OPEN_TIMEOUT_MS;
306
+ const loginPtyCloseTimeoutMs = options.loginPtyLimits?.closeTimeoutMs ?? LOGIN_PTY_CLOSE_TIMEOUT_MS;
307
+ // Bounds and timeouts for the generic duplex channel route. A caller (a test)
308
+ // can lower them to exercise each bound and the terminalize paths. Each
309
+ // "Chars" name is a historical holdover: the channel now carries `Uint8Array`
310
+ // chunks, so every one of these bounds counts raw bytes (`.length` on a
311
+ // `Uint8Array` is its byte count), not UTF-16 code units.
312
+ const maxDuplexChannelChunkChars = options.duplexChannelLimits?.maxChunkChars ?? MAX_DUPLEX_CHANNEL_CHUNK_CHARS;
313
+ const maxDuplexChannelPreBindChars = options.duplexChannelLimits?.maxPreBindBufferedChars ??
314
+ MAX_DUPLEX_CHANNEL_PRE_BIND_CHARS;
315
+ const maxDuplexChannelPreBindFrames = options.duplexChannelLimits?.maxPreBindBufferedFrames ??
316
+ MAX_DUPLEX_CHANNEL_PRE_BIND_FRAMES;
317
+ // The pre-bind hold ceiling stays one frame above the buffered bound, so the
318
+ // replay, not the hold, ends the route on the buffered bound. See
319
+ // DUPLEX_CHANNEL_PRE_BIND_HOLD_MARGIN_FRAMES for why the margin must hold.
320
+ const maxDuplexChannelPreBindHoldFrames = maxDuplexChannelPreBindFrames + DUPLEX_CHANNEL_PRE_BIND_HOLD_MARGIN_FRAMES;
321
+ const maxDuplexChannelPendingRequests = options.duplexChannelLimits?.maxPendingRequests ??
322
+ MAX_DUPLEX_CHANNEL_PENDING_REQUESTS;
323
+ const maxDuplexChannelWriteChars = options.duplexChannelLimits?.maxWriteChars ?? MAX_DUPLEX_CHANNEL_WRITE_CHARS;
324
+ const maxDuplexChannelProtocolErrors = options.duplexChannelLimits?.maxProtocolErrors ??
325
+ MAX_DUPLEX_CHANNEL_PROTOCOL_ERRORS;
326
+ const maxDuplexChannelTotalDataBytes = options.duplexChannelLimits?.maxTotalDataBytes ??
327
+ MAX_DUPLEX_CHANNEL_TOTAL_DATA_BYTES;
328
+ const maxDuplexChannelDurationMs = options.duplexChannelLimits?.maxDurationMs ?? MAX_DUPLEX_CHANNEL_DURATION_MS;
329
+ const duplexChannelOpenTimeoutMs = options.duplexChannelLimits?.openTimeoutMs ?? DUPLEX_CHANNEL_OPEN_TIMEOUT_MS;
330
+ const duplexChannelCloseTimeoutMs = options.duplexChannelLimits?.closeTimeoutMs ?? DUPLEX_CHANNEL_CLOSE_TIMEOUT_MS;
197
331
  // ------------------------------------------------------------------
198
332
  // Proactive company scopes (LOOA-629)
199
333
  // ------------------------------------------------------------------
@@ -250,11 +384,42 @@ export function createPluginWorkerHandle(pluginId, options) {
250
384
  // -----------------------------------------------------------------------
251
385
  // JSON-RPC message sending
252
386
  // -----------------------------------------------------------------------
253
- function sendMessage(message) {
387
+ function sendMessage(message, meterDuplexWrite = false) {
254
388
  if (!childProcess?.stdin?.writable) {
255
389
  throw new Error(`Worker process for plugin "${pluginId}" is not writable`);
256
390
  }
257
391
  const serialized = serializeMessage(message);
392
+ const ledger = duplexAggregateByteLedger;
393
+ if (meterDuplexWrite && ledger) {
394
+ // Charge the child-stdin transport buffer for a duplex write. The host writes
395
+ // the serialized frame to the child stdin without backpressure. When the child
396
+ // stops reading its stdin, the frame stays in the host stdin write buffer.
397
+ // Reserve the exact serialized-frame byte count. That count includes the JSON
398
+ // escaping and the newline framing, so the ledger covers the retained transport
399
+ // bytes, not only the raw payload. The RPC separately holds the raw payload
400
+ // under a `pending_write` token, so the two tokens cover the peak of both
401
+ // retentions at the same time.
402
+ const bytes = Buffer.byteLength(serialized);
403
+ const token = ledger.reserve("stdin_write", bytes);
404
+ if (!token) {
405
+ // The reservation would pass the aggregate ceiling. Fail closed before the
406
+ // enqueue: do not write the frame. The duplex write caller ends the route.
407
+ throw new DuplexAggregateBytesExceededError();
408
+ }
409
+ // Hold the token until the stream flushes the chunk. The write callback fires
410
+ // when the stream hands the chunk to the operating system, so the bytes then
411
+ // leave the host stream buffer. The RPC settle and the RPC timeout never
412
+ // release this token. Only the flush, the stream error, the stream close, or
413
+ // the worker exit releases it. Release through the outstanding-token set, so a
414
+ // later stream-error or worker-exit sweep never double-releases the same token.
415
+ pendingStdinWriteTokens.add(token);
416
+ childProcess.stdin.write(serialized, () => {
417
+ if (pendingStdinWriteTokens.delete(token)) {
418
+ ledger.release(token);
419
+ }
420
+ });
421
+ return;
422
+ }
258
423
  childProcess.stdin.write(serialized);
259
424
  }
260
425
  function errorCodeForWorkerHostError(err) {
@@ -509,22 +674,37 @@ export function createPluginWorkerHandle(pluginId, options) {
509
674
  log.error({ err: err instanceof Error ? err.message : String(err) }, "execute.log delivery threw");
510
675
  }
511
676
  }
512
- // At most one active credential pseudo-terminal per worker. A non-null route
513
- // blocks a second open until the manager confirms the first route's close.
514
- let setupTokenPtyRoute = null;
515
- function settleSetupTokenPtyWait(route, value) {
677
+ // Shared route-binding helpers. The login pseudo-terminal route and the duplex
678
+ // channel route both use them, so the two routes bind and settle one way.
679
+ // Settle the route wait exactly once. Replace the settler with a no-op, so a
680
+ // later exit or terminalize never settles the wait a second time.
681
+ function settleRouteWait(route, value) {
516
682
  const settle = route.settleWait;
517
683
  route.settleWait = () => { };
518
684
  settle(value);
519
685
  }
686
+ // Read the worker session identifier from an open reply, but only when the
687
+ // route can still bind. Return null for a malformed reply, or for a route that
688
+ // already left `opening` or terminalized. A late or a duplicate reply never
689
+ // binds, revives, or reopens a route.
690
+ function readBindableWorkerSessionId(route, openResult) {
691
+ const workerSessionId = readNonEmptyString(isRecord(openResult) ? openResult.workerSessionId : null);
692
+ if (!workerSessionId || route.state !== "opening" || route.terminalized) {
693
+ return null;
694
+ }
695
+ return workerSessionId;
696
+ }
697
+ // At most one active credential pseudo-terminal per worker. A non-null route
698
+ // blocks a second open until the manager confirms the first route's close.
699
+ let loginPtyRoute = null;
520
700
  // Close the worker terminal by the host route identifier and verify the bound
521
701
  // acknowledgement. Return true only when the worker returns an acknowledgement
522
702
  // that carries the exact host route identifier. An absent, malformed,
523
703
  // mismatched, or timed-out acknowledgement returns false, so the caller fails
524
704
  // closed.
525
- async function closeSetupTokenPtyTerminal(hostRouteId) {
705
+ async function closeLoginPtyTerminal(hostRouteId) {
526
706
  try {
527
- const ack = await callInternal("setupTokenPtyClose", { hostRouteId }, setupTokenPtyCloseTimeoutMs);
707
+ const ack = await callInternal("loginPtyClose", { hostRouteId }, loginPtyCloseTimeoutMs);
528
708
  return isRecord(ack) && readNonEmptyString(ack.hostRouteId) === hostRouteId;
529
709
  }
530
710
  catch {
@@ -534,7 +714,7 @@ export function createPluginWorkerHandle(pluginId, options) {
534
714
  // Terminalize the route exactly once. Resolve the login wait, close the worker
535
715
  // terminal by the host route identifier, and free the per-worker slot only
536
716
  // after the close resolves. Retire the worker when the close is unconfirmed.
537
- async function terminalizeSetupTokenPtyRoute(route) {
717
+ async function terminalizeLoginPtyRoute(route) {
538
718
  if (route.terminalized)
539
719
  return;
540
720
  route.terminalized = true;
@@ -543,14 +723,14 @@ export function createPluginWorkerHandle(pluginId, options) {
543
723
  route.buffered = [];
544
724
  // A terminalized route reports a null exit code, which the runner treats as a
545
725
  // failure.
546
- settleSetupTokenPtyWait(route, { exitCode: null });
547
- const confirmed = await closeSetupTokenPtyTerminal(route.hostRouteId);
548
- if (setupTokenPtyRoute === route)
549
- setupTokenPtyRoute = null;
726
+ settleRouteWait(route, { exitCode: null });
727
+ const confirmed = await closeLoginPtyTerminal(route.hostRouteId);
728
+ if (loginPtyRoute === route)
729
+ loginPtyRoute = null;
550
730
  if (!confirmed) {
551
731
  // The worker did not acknowledge the close, so the host cannot prove the
552
732
  // terminal is gone. Fail closed: retire the worker before any reuse.
553
- log.error({ pluginId }, "setup-token login pseudo-terminal close not acknowledged; retiring worker");
733
+ log.error({ pluginId }, "login pseudo-terminal close not acknowledged; retiring worker");
554
734
  void killProcess();
555
735
  }
556
736
  }
@@ -558,8 +738,8 @@ export function createPluginWorkerHandle(pluginId, options) {
558
738
  // listener. Deliver only while the route is `open` and the notification carries
559
739
  // the exact bound worker session identifier and valid bounded bytes. Drop an
560
740
  // unknown, late, malformed, or mismatched notification. Never log the raw bytes.
561
- function routeSetupTokenPtyOutput(notification) {
562
- const route = setupTokenPtyRoute;
741
+ function routeLoginPtyOutput(notification) {
742
+ const route = loginPtyRoute;
563
743
  if (!route || route.state !== "open")
564
744
  return;
565
745
  const params = isRecord(notification.params) ? notification.params : {};
@@ -569,12 +749,12 @@ export function createPluginWorkerHandle(pluginId, options) {
569
749
  const chunk = params.chunk;
570
750
  if (typeof chunk !== "string" ||
571
751
  chunk.length === 0 ||
572
- chunk.length > maxSetupTokenPtyChunkChars) {
752
+ chunk.length > maxLoginPtyChunkChars) {
573
753
  return;
574
754
  }
575
- if (route.deliveredChars + chunk.length > maxSetupTokenPtyTotalChars) {
755
+ if (route.deliveredChars + chunk.length > maxLoginPtyTotalChars) {
576
756
  // The cumulative output passed the per-route bound. Terminalize the route.
577
- void terminalizeSetupTokenPtyRoute(route);
757
+ void terminalizeLoginPtyRoute(route);
578
758
  return;
579
759
  }
580
760
  route.deliveredChars += chunk.length;
@@ -586,8 +766,8 @@ export function createPluginWorkerHandle(pluginId, options) {
586
766
  // Route one login pseudo-terminal exit notification to the login wait. Resolve
587
767
  // only while the route is `open` and the notification carries the exact bound
588
768
  // worker session identifier.
589
- function routeSetupTokenPtyExit(notification) {
590
- const route = setupTokenPtyRoute;
769
+ function routeLoginPtyExit(notification) {
770
+ const route = loginPtyRoute;
591
771
  if (!route || route.state !== "open")
592
772
  return;
593
773
  const params = isRecord(notification.params) ? notification.params : {};
@@ -595,38 +775,42 @@ export function createPluginWorkerHandle(pluginId, options) {
595
775
  if (!workerSessionId || workerSessionId !== route.workerSessionId)
596
776
  return;
597
777
  const exitCode = typeof params.exitCode === "number" ? params.exitCode : null;
598
- settleSetupTokenPtyWait(route, { exitCode });
778
+ settleRouteWait(route, { exitCode });
599
779
  }
600
780
  // Close the one route on a worker exit. The worker is gone, so the manager
601
781
  // resolves the login wait with the fixed non-secret exit and clears the route
602
782
  // one time. The pending pseudo-terminal calls reject through `rejectAllPending`.
603
- function closeSetupTokenPtyRouteOnWorkerExit() {
604
- const route = setupTokenPtyRoute;
783
+ function closeLoginPtyRouteOnWorkerExit() {
784
+ const route = loginPtyRoute;
605
785
  if (!route)
606
786
  return;
607
- setupTokenPtyRoute = null;
787
+ loginPtyRoute = null;
608
788
  route.terminalized = true;
609
789
  route.state = "closed";
610
790
  route.listener = null;
611
791
  route.buffered = [];
612
- settleSetupTokenPtyWait(route, { exitCode: null });
792
+ settleRouteWait(route, { exitCode: null });
613
793
  }
614
794
  // Open one live login pseudo-terminal route. Reserve the route
615
795
  // before the open call, bind the worker session identifier one time on the
616
796
  // first successful open reply, and return a session the login transport drives.
617
797
  // Terminalize the route on every open failure path.
618
- async function openSetupTokenPtySession(input) {
619
- if (input.command !== CLAUDE_SETUP_TOKEN_COMMAND) {
620
- // Allowlist the login command. Only the fixed `CLAUDE_SETUP_TOKEN_COMMAND`
621
- // may run in the sandbox pseudo-terminal. Reject any other command with one
622
- // fixed non-secret error before the worker call, so a caller cannot spawn
623
- // an arbitrary process in the sandbox pseudo-terminal.
624
- throw new Error(SETUP_TOKEN_PTY_COMMAND_NOT_ALLOWED);
625
- }
626
- if (setupTokenPtyRoute) {
798
+ async function openLoginPtySession(input) {
799
+ if (!isLoginCommandKey(input.loginCommandKey)) {
800
+ // Allowlist the login command key. Only a key in the closed set may open a
801
+ // sandbox pseudo-terminal. Reject a key outside the set with one fixed
802
+ // non-secret error before the worker call, so a caller cannot select an
803
+ // arbitrary command in the sandbox pseudo-terminal.
804
+ throw new Error(LOGIN_PTY_COMMAND_NOT_ALLOWED);
805
+ }
806
+ // Revalidate the server-controlled session home shape before the worker RPC.
807
+ // The binding validated it at the service boundary; this is the last host gate
808
+ // before the worker call, so a malformed home fails closed here too.
809
+ validateLoginSessionHome(input.sessionHome);
810
+ if (loginPtyRoute) {
627
811
  // A route for this worker is not yet closed and confirmed. Reject the
628
812
  // second open with one fixed non-secret error before it reaches the worker.
629
- throw new Error(SETUP_TOKEN_PTY_ROUTE_BUSY);
813
+ throw new Error(LOGIN_PTY_ROUTE_BUSY);
630
814
  }
631
815
  const hostRouteId = randomUUID();
632
816
  let settleWait = () => { };
@@ -643,31 +827,32 @@ export function createPluginWorkerHandle(pluginId, options) {
643
827
  terminalized: false,
644
828
  settleWait,
645
829
  };
646
- setupTokenPtyRoute = route;
830
+ loginPtyRoute = route;
647
831
  route.state = "opening";
648
832
  let openResult;
649
833
  try {
650
- openResult = await callInternal("setupTokenPtyOpen", {
834
+ openResult = await callInternal("loginPtyOpen", {
651
835
  hostRouteId,
652
836
  driverKey: input.driverKey,
653
837
  companyId: input.companyId,
654
838
  environmentId: input.environmentId,
655
839
  providerLeaseId: input.providerLeaseId,
656
- command: input.command,
657
- }, setupTokenPtyOpenTimeoutMs);
840
+ loginCommandKey: input.loginCommandKey,
841
+ sessionHome: input.sessionHome,
842
+ }, loginPtyOpenTimeoutMs);
658
843
  }
659
844
  catch (err) {
660
845
  // A send failure, an RPC rejection, or an open timeout. Terminalize the
661
846
  // route exactly once and fail closed.
662
- await terminalizeSetupTokenPtyRoute(route);
663
- throw err instanceof Error ? err : new Error(SETUP_TOKEN_PTY_OPEN_FAILED);
847
+ await terminalizeLoginPtyRoute(route);
848
+ throw err instanceof Error ? err : new Error(LOGIN_PTY_OPEN_FAILED);
664
849
  }
665
- const workerSessionId = readNonEmptyString(isRecord(openResult) ? openResult.workerSessionId : null);
666
- if (!workerSessionId || route.state !== "opening" || route.terminalized) {
850
+ const workerSessionId = readBindableWorkerSessionId(route, openResult);
851
+ if (!workerSessionId) {
667
852
  // A malformed reply, or a route that already left `opening`. A late or a
668
853
  // duplicate reply never binds, revives, or reopens a route.
669
- await terminalizeSetupTokenPtyRoute(route);
670
- throw new Error(SETUP_TOKEN_PTY_OPEN_FAILED);
854
+ await terminalizeLoginPtyRoute(route);
855
+ throw new Error(LOGIN_PTY_OPEN_FAILED);
671
856
  }
672
857
  // Bind the worker session identifier one time and move the route to `open`.
673
858
  route.workerSessionId = workerSessionId;
@@ -686,7 +871,7 @@ export function createPluginWorkerHandle(pluginId, options) {
686
871
  const sid = route.workerSessionId;
687
872
  if (route.state !== "open" || !sid)
688
873
  return;
689
- void callInternal("setupTokenPtyInput", { workerSessionId: sid, data }, setupTokenPtyOpenTimeoutMs).catch(() => { });
874
+ void callInternal("loginPtyInput", { workerSessionId: sid, data }, loginPtyOpenTimeoutMs).catch(() => { });
690
875
  },
691
876
  wait() {
692
877
  return waitPromise;
@@ -695,10 +880,796 @@ export function createPluginWorkerHandle(pluginId, options) {
695
880
  const sid = route.workerSessionId;
696
881
  if (!sid)
697
882
  return;
698
- void callInternal("setupTokenPtyStop", { workerSessionId: sid }, setupTokenPtyOpenTimeoutMs).catch(() => { });
883
+ void callInternal("loginPtyStop", { workerSessionId: sid }, loginPtyOpenTimeoutMs).catch(() => { });
699
884
  },
700
885
  async close() {
701
- await terminalizeSetupTokenPtyRoute(route);
886
+ await terminalizeLoginPtyRoute(route);
887
+ },
888
+ };
889
+ }
890
+ // The live duplex routes on this worker, keyed by the exact
891
+ // `{ hostRouteId, workerSessionId }` pair. The host binds one pair once, at
892
+ // open, and routes each data, exit, and close frame only to the route that owns
893
+ // its exact pair. The host never routes by the worker session id alone.
894
+ const liveDuplexRoutes = new Map();
895
+ // The reserved-or-opening routes, keyed by the host route id. A route lives here
896
+ // from the open call until the worker session id binds. The host then moves it
897
+ // to `liveDuplexRoutes` under the exact pair key.
898
+ const openingDuplexRoutes = new Map();
899
+ // The tombstoned pairs on this worker, keyed by the exact pair key. The host
900
+ // installs a tombstone atomically when it removes a live binding, and retains it
901
+ // until the worker retires. A late frame for a tombstoned pair reaches no
902
+ // listener. A closed pair never returns.
903
+ const duplexPairTombstones = new Set();
904
+ // The process-scoped aggregate route-slot controller. The manager injects it, so
905
+ // the ceiling counts every duplex route across every worker in the process, not
906
+ // one agent's setting. When it is absent, the worker admits every open, so the
907
+ // handle runs unbounded in isolation (a unit test constructs it this way).
908
+ const duplexRouteSlots = options.duplexRouteSlots ?? null;
909
+ // The routes that currently hold one aggregate slot. The host releases a slot one
910
+ // time per route, so a double terminalize never releases two slots.
911
+ const duplexRouteSlotHolders = new Set();
912
+ // The process-owned aggregate byte ledger. The manager injects it, so one gauge
913
+ // bounds the aggregate retained bytes across every route in the process. When it
914
+ // is absent, the worker retains duplex bytes unbounded (a unit test constructs it
915
+ // this way).
916
+ const duplexAggregateByteLedger = options.duplexAggregateByteLedger ?? null;
917
+ // The outstanding child-stdin transport tokens for duplex writes. Each token
918
+ // covers one serialized frame the host stdin write buffer still retains. The
919
+ // write callback releases a token on the flush; a stream error, a stream close,
920
+ // or a worker exit releases every remaining token, because each of those
921
+ // discards the stdin write buffer. The set is the release guard, so a token
922
+ // releases one time across the two paths.
923
+ const pendingStdinWriteTokens = new Set();
924
+ // Release every outstanding child-stdin transport token. A stream error, a
925
+ // stream close, or a worker exit calls this, because each discards the stdin
926
+ // write buffer. The `delete` guard drops each token one time, so a later flush
927
+ // callback or a second sweep releases nothing again.
928
+ function releaseAllPendingStdinWriteTokens() {
929
+ if (!duplexAggregateByteLedger)
930
+ return;
931
+ for (const token of pendingStdinWriteTokens) {
932
+ duplexAggregateByteLedger.release(token);
933
+ }
934
+ pendingStdinWriteTokens.clear();
935
+ }
936
+ // The terminalized routes that still hold buffered bytes for a late listener
937
+ // drain. A terminalized route leaves the opening and live maps, so this registry
938
+ // keeps the worker-exit sweep able to release its still-charged buffered tokens.
939
+ const terminalDuplexRoutes = new Set();
940
+ // Reserve `bytes` for one route retention against the aggregate ledger. Return
941
+ // the held token, or `null` when the reservation would pass the ceiling. When no
942
+ // ledger is present, return `"no-ledger"`, so the caller admits the retention
943
+ // with no token. The helper adds a real token to `route.retainedTokens`.
944
+ function reserveRouteBytes(route, owner, bytes) {
945
+ if (!duplexAggregateByteLedger)
946
+ return "no-ledger";
947
+ const token = duplexAggregateByteLedger.reserve(owner, bytes);
948
+ if (!token)
949
+ return null;
950
+ route.retainedTokens.add(token);
951
+ return token;
952
+ }
953
+ // Transfer a held route token to a new owner label. The token identity and the
954
+ // reserved bytes stay the same, so no admission gap opens between two
955
+ // representations of the same retained bytes.
956
+ function transferRouteToken(token, owner) {
957
+ if (!token)
958
+ return;
959
+ duplexAggregateByteLedger?.transfer(token, owner);
960
+ }
961
+ // Release one held route token and drop it from the route registry, in one
962
+ // synchronous step. A `null` token (no ledger) releases nothing.
963
+ function releaseRouteToken(route, token) {
964
+ if (!token)
965
+ return;
966
+ route.retainedTokens.delete(token);
967
+ duplexAggregateByteLedger?.release(token);
968
+ }
969
+ // Release every token a route still holds and clear its retained representations,
970
+ // exactly once. A later call finds an empty registry and releases nothing, so the
971
+ // helper is idempotent. Every terminal-discard, open-failure, bind-replay
972
+ // failure, close-acknowledgement failure, worker-exit, and shutdown path calls
973
+ // it. Terminal map deletion is never treated as proof that retained bytes are
974
+ // gone; this registry is authoritative for byte cleanup.
975
+ function discardRouteRetained(route) {
976
+ if (duplexAggregateByteLedger) {
977
+ for (const token of route.retainedTokens) {
978
+ duplexAggregateByteLedger.release(token);
979
+ }
980
+ }
981
+ route.retainedTokens.clear();
982
+ route.preBind = [];
983
+ route.preBindExit = null;
984
+ route.buffered = [];
985
+ route.bufferedChars = 0;
986
+ terminalDuplexRoutes.delete(route);
987
+ }
988
+ // Try to reserve one aggregate route slot for a route. Return true when the
989
+ // route holds a slot after the call. When no controller is present, the route
990
+ // always holds a slot.
991
+ function acquireDuplexRouteSlot(route) {
992
+ if (!duplexRouteSlots)
993
+ return true;
994
+ if (!duplexRouteSlots.tryAcquire())
995
+ return false;
996
+ duplexRouteSlotHolders.add(route);
997
+ return true;
998
+ }
999
+ // Release the aggregate route slot a route holds, one time. A route that never
1000
+ // held a slot releases nothing.
1001
+ function releaseDuplexRouteSlot(route) {
1002
+ if (!duplexRouteSlotHolders.delete(route))
1003
+ return;
1004
+ duplexRouteSlots?.release();
1005
+ }
1006
+ // Retire the worker at once. The host kills the process, so every live route,
1007
+ // every reserved route, and every tombstone drops together. The host uses this
1008
+ // as the fail-closed response to an ownership violation and to a tombstone-set
1009
+ // overflow. The host never logs the raw frame content on a violation.
1010
+ function retireDuplexWorkerOnViolation(reason) {
1011
+ log.error({ pluginId }, `duplex channel ownership violation (${reason}); retiring worker`);
1012
+ void killProcess();
1013
+ }
1014
+ // Install a tombstone for a bound pair atomically with the removal of its live
1015
+ // binding. This runs in one synchronous step, before any slot release or reuse,
1016
+ // so a late frame for the pair reaches no listener. Before the set would exceed
1017
+ // its hard bound, retire the worker, which drops every route and every tombstone
1018
+ // at once. A tombstone overflow fails closed; the host never evicts a tombstone
1019
+ // and lets a pair return.
1020
+ function tombstoneBoundDuplexPair(route) {
1021
+ if (route.workerSessionId === null)
1022
+ return;
1023
+ const pairKey = duplexPairKey(route.hostRouteId, route.workerSessionId);
1024
+ liveDuplexRoutes.delete(pairKey);
1025
+ if (!duplexPairTombstones.has(pairKey)) {
1026
+ if (duplexPairTombstones.size >= MAX_DUPLEX_ROUTE_TOMBSTONES) {
1027
+ // The set is full. Do not evict a tombstone. Retire the worker, which
1028
+ // drops every route and every tombstone together, so no closed pair ever
1029
+ // returns.
1030
+ retireDuplexWorkerOnViolation("tombstone set overflow");
1031
+ return;
1032
+ }
1033
+ duplexPairTombstones.add(pairKey);
1034
+ }
1035
+ }
1036
+ // Close the worker channel by the host route identifier and verify the bound
1037
+ // acknowledgement. Return true only when the worker returns an acknowledgement
1038
+ // for the exact pair. Before a session binds, the acknowledgement carries the
1039
+ // host route identifier only, so a lost open reply still permits a route-only
1040
+ // close. After a session binds, the acknowledgement must echo both the host
1041
+ // route identifier and the bound worker session identifier. An absent,
1042
+ // malformed, mismatched, or timed-out acknowledgement returns false, so the
1043
+ // caller fails closed.
1044
+ async function closeDuplexChannelTerminal(hostRouteId, boundWorkerSessionId) {
1045
+ try {
1046
+ const ack = await callInternal("duplexChannelClose", { hostRouteId }, duplexChannelCloseTimeoutMs);
1047
+ if (!isRecord(ack) || readNonEmptyString(ack.hostRouteId) !== hostRouteId) {
1048
+ return false;
1049
+ }
1050
+ if (boundWorkerSessionId !== null) {
1051
+ // A bound close: the acknowledgement must echo the exact worker session
1052
+ // identifier. A missing or a mismatched identifier is invalid.
1053
+ return readNonEmptyString(ack.workerSessionId) === boundWorkerSessionId;
1054
+ }
1055
+ return true;
1056
+ }
1057
+ catch {
1058
+ return false;
1059
+ }
1060
+ }
1061
+ // Terminalize the route exactly once. Resolve the wait, close the worker
1062
+ // channel by the host route identifier, and free the per-worker slot only
1063
+ // after the close resolves. Retire the worker when the close is unconfirmed.
1064
+ // Clear the route lifetime timer one time. Every terminal path and the
1065
+ // worker-exit path calls this, so a timer never fires after the route ends.
1066
+ function clearDuplexChannelLifetimeTimer(route) {
1067
+ if (route.lifetimeTimer) {
1068
+ clearTimeout(route.lifetimeTimer);
1069
+ route.lifetimeTimer = null;
1070
+ }
1071
+ }
1072
+ async function terminalizeDuplexChannelRoute(route) {
1073
+ if (route.terminalized)
1074
+ return;
1075
+ route.terminalized = true;
1076
+ route.state = "closed";
1077
+ route.listener = null;
1078
+ // Stop admission first. Then move the byte cleanup. Keep the buffered chunks the
1079
+ // host accepted before the route ended, so a listener that attaches after the
1080
+ // end still drains them. A frame can end the route during the pre-bind replay,
1081
+ // before a listener attaches, and the chunks the host accepted before that frame
1082
+ // are valid data the listener must still receive. The buffered bytes stay
1083
+ // bounded by the pre-bind buffered bound, and `onData` clears and releases them
1084
+ // once it drains them.
1085
+ //
1086
+ // The buffered records keep their tokens; move each token to the terminal owner
1087
+ // label and register the route in the terminal registry, so the worker-exit
1088
+ // sweep can release the still-charged bytes later. Do not release a buffered
1089
+ // token at map deletion; terminal map deletion is never proof the bytes are
1090
+ // gone. Release every other token the route still holds (the pre-bind events,
1091
+ // the held exit, and any stranded token), because they never reach a listener.
1092
+ const bufferedTokens = new Set();
1093
+ for (const record of route.buffered) {
1094
+ if (record.token) {
1095
+ bufferedTokens.add(record.token);
1096
+ transferRouteToken(record.token, "terminal_buffered");
1097
+ }
1098
+ }
1099
+ for (const token of [...route.retainedTokens]) {
1100
+ if (!bufferedTokens.has(token)) {
1101
+ route.retainedTokens.delete(token);
1102
+ duplexAggregateByteLedger?.release(token);
1103
+ }
1104
+ }
1105
+ route.preBind = [];
1106
+ route.preBindExit = null;
1107
+ if (route.buffered.length > 0) {
1108
+ terminalDuplexRoutes.add(route);
1109
+ }
1110
+ clearDuplexChannelLifetimeTimer(route);
1111
+ // Remove the live binding and install the tombstone in one synchronous step,
1112
+ // before the worker close and before any reuse. A reserved route that never
1113
+ // bound leaves the opening map only; it has no pair to tombstone, and the
1114
+ // monotonic host route id never returns.
1115
+ openingDuplexRoutes.delete(route.hostRouteId);
1116
+ tombstoneBoundDuplexPair(route);
1117
+ releaseDuplexRouteSlot(route);
1118
+ // A terminalized route reports a null exit code, which the caller treats as a
1119
+ // failure.
1120
+ settleRouteWait(route, { exitCode: null });
1121
+ const confirmed = await closeDuplexChannelTerminal(route.hostRouteId, route.workerSessionId);
1122
+ if (!confirmed) {
1123
+ // The worker did not acknowledge the close, so the host cannot prove the
1124
+ // channel is gone. Fail closed: retire the worker before any reuse.
1125
+ log.error({ pluginId }, "duplex channel close not acknowledged; retiring worker");
1126
+ void killProcess();
1127
+ }
1128
+ }
1129
+ // Count one protocol error for the route. End the route when the count passes
1130
+ // the per-route budget, so a flood of malformed or mismatched frames bounds the
1131
+ // route.
1132
+ function recordDuplexChannelProtocolError(route) {
1133
+ route.protocolErrors += 1;
1134
+ if (route.protocolErrors > maxDuplexChannelProtocolErrors) {
1135
+ void terminalizeDuplexChannelRoute(route);
1136
+ }
1137
+ }
1138
+ // Deliver one duplex channel chunk to the bound listener in isolation. A
1139
+ // listener that throws must not escape the worker stdout notification handler
1140
+ // or the buffered replay, so a throw here breaks neither the notification
1141
+ // dispatch loop nor the pre-bind drain. The manager catches the error and logs
1142
+ // it without the raw bytes. This mirrors the `execute.log` delivery isolation.
1143
+ function deliverDuplexChannelChunk(listener, chunk) {
1144
+ try {
1145
+ listener(chunk);
1146
+ }
1147
+ catch (err) {
1148
+ log.error({ err: err instanceof Error ? err.message : String(err) }, "duplex channel data delivery threw");
1149
+ }
1150
+ }
1151
+ // Route one duplex channel data notification to the per-session listener.
1152
+ // Deliver only while the route is `open` and the notification carries the exact
1153
+ // bound worker session identifier and a valid chunk. Count a mismatched or
1154
+ // malformed frame as a protocol error. End the route at once when one chunk is
1155
+ // larger than the per-chunk limit or when the cumulative bytes pass the total
1156
+ // cap. Buffer a valid frame under the pre-bind bounds when no listener has
1157
+ // attached yet. Never log the raw bytes.
1158
+ // Resolve one inbound duplex frame to its owning route by the exact pair. Return
1159
+ // the live route on an exact live-pair match. Return `"tombstoned"` for a late
1160
+ // frame whose exact pair the host already closed; that frame reaches no listener
1161
+ // and changes no state. Return `"violation"` for any unknown, foreign,
1162
+ // duplicate, ambiguous, or malformed pair; the caller fails closed and retires
1163
+ // the worker. The lookup never routes by the worker session id alone.
1164
+ function resolveDuplexRouteByPair(hostRouteId, workerSessionId) {
1165
+ if (!hostRouteId || !workerSessionId)
1166
+ return "violation";
1167
+ const pairKey = duplexPairKey(hostRouteId, workerSessionId);
1168
+ const live = liveDuplexRoutes.get(pairKey);
1169
+ if (live && live.state === "open")
1170
+ return live;
1171
+ if (duplexPairTombstones.has(pairKey))
1172
+ return "tombstoned";
1173
+ // A frame whose host route id names a route that is still opening arrived
1174
+ // before the bind. It is the host's own reserved route, not a foreign frame.
1175
+ // The caller defers it, so the bind replays it through the exact-pair routing.
1176
+ if (openingDuplexRoutes.has(hostRouteId))
1177
+ return "opening";
1178
+ return "violation";
1179
+ }
1180
+ // Route one duplex channel data frame. On a live route with a listener, deliver
1181
+ // the chunk transiently. On a live route with no listener, buffer the chunk and
1182
+ // charge its raw bytes against the aggregate ledger. The `carried` argument marks
1183
+ // a bind-replay frame: it carries the pre-bind token the buffered record must
1184
+ // reuse, so the replay transfers the token to the buffered representation with no
1185
+ // decrement and no re-reserve. A live frame passes no `carried`, so it reserves a
1186
+ // fresh buffered token. A `carried.token` of `null` means no ledger is present.
1187
+ function routeDuplexChannelData(notification, carried) {
1188
+ const params = isRecord(notification.params) ? notification.params : {};
1189
+ const hostRouteId = readNonEmptyString(params.hostRouteId);
1190
+ const workerSessionId = readNonEmptyString(params.workerSessionId);
1191
+ const resolved = resolveDuplexRouteByPair(hostRouteId, workerSessionId);
1192
+ if (resolved === "tombstoned") {
1193
+ // A late frame for a closed pair. It reaches no listener and changes no
1194
+ // state. A bind replay never lands here, because the replayed route is live;
1195
+ // a carried token stays on its route and the terminal cleanup releases it.
1196
+ // Never log the raw frame content.
1197
+ return;
1198
+ }
1199
+ if (resolved === "violation") {
1200
+ // An unknown, foreign, duplicate, ambiguous, or malformed pair. Fail closed:
1201
+ // retire the worker. Never log the raw frame content.
1202
+ retireDuplexWorkerOnViolation("data frame pair");
1203
+ return;
1204
+ }
1205
+ if (resolved === "opening") {
1206
+ // The frame arrived before the bind. A replay never lands here, because the
1207
+ // route is live during replay. Hold the live frame; the bind replays it.
1208
+ bufferPreBindDuplexFrame(hostRouteId, notification);
1209
+ return;
1210
+ }
1211
+ const route = resolved;
1212
+ // The wire carries the chunk as a base64 string (JSON has no binary type; see
1213
+ // `ChannelBytesWireValue` in protocol.ts). Decode it back to raw bytes before
1214
+ // any bound check, so every bound below counts real bytes, not base64 text.
1215
+ // A bind replay (`replayPreBindDuplexFrames`) reuses this function with a
1216
+ // held event whose chunk the host already decoded once; it carries the
1217
+ // already-decoded `Uint8Array` straight through with no second decode.
1218
+ const rawChunk = params.chunk;
1219
+ const chunk = rawChunk instanceof Uint8Array ? rawChunk : decodeChannelBytes(rawChunk);
1220
+ if (chunk === null || chunk.byteLength === 0) {
1221
+ // The exact pair matches, but the chunk is malformed or empty. Count one
1222
+ // per-route protocol error. Release a carried replay token first.
1223
+ releaseRouteToken(route, carried?.token ?? null);
1224
+ recordDuplexChannelProtocolError(route);
1225
+ return;
1226
+ }
1227
+ if (chunk.byteLength > maxDuplexChannelChunkChars) {
1228
+ // One inbound chunk is larger than the per-chunk limit. End the route at
1229
+ // once. Do not count the chunk as a protocol error.
1230
+ releaseRouteToken(route, carried?.token ?? null);
1231
+ void terminalizeDuplexChannelRoute(route);
1232
+ return;
1233
+ }
1234
+ // Enforce the cumulative total-byte cap before and after a listener attaches.
1235
+ // End the route when the cap is exceeded, so a bound listener cannot receive
1236
+ // data past the cap.
1237
+ const chunkBytes = chunk.byteLength;
1238
+ if (route.totalDataBytes + chunkBytes > maxDuplexChannelTotalDataBytes) {
1239
+ releaseRouteToken(route, carried?.token ?? null);
1240
+ void terminalizeDuplexChannelRoute(route);
1241
+ return;
1242
+ }
1243
+ route.totalDataBytes += chunkBytes;
1244
+ if (route.listener) {
1245
+ // A listener is attached. Deliver the chunk transiently and release a carried
1246
+ // replay token after the synchronous delivery boundary.
1247
+ deliverDuplexChannelChunk(route.listener, chunk);
1248
+ releaseRouteToken(route, carried?.token ?? null);
1249
+ return;
1250
+ }
1251
+ // No listener attached yet. Buffer the frame under the pre-bind bounds. End
1252
+ // the route when the cumulative bytes or the frame count passes the bound.
1253
+ if (route.buffered.length + 1 > maxDuplexChannelPreBindFrames ||
1254
+ route.bufferedChars + chunk.length > maxDuplexChannelPreBindChars) {
1255
+ releaseRouteToken(route, carried?.token ?? null);
1256
+ void terminalizeDuplexChannelRoute(route);
1257
+ return;
1258
+ }
1259
+ let token;
1260
+ if (carried) {
1261
+ // The bind replay transfers the pre-bind token to the buffered
1262
+ // representation. No decrement and no re-reserve, so no admission gap opens.
1263
+ token = carried.token;
1264
+ transferRouteToken(token, "buffered_chunk");
1265
+ }
1266
+ else {
1267
+ // A live frame reserves a fresh buffered token for its exact raw bytes.
1268
+ const reserved = reserveRouteBytes(route, "buffered_chunk", chunkBytes);
1269
+ if (reserved === null) {
1270
+ // The aggregate ceiling rejected the reservation. Retain nothing and fail
1271
+ // closed with the fixed marker.
1272
+ void terminalizeDuplexChannelRoute(route);
1273
+ return;
1274
+ }
1275
+ token = reserved === "no-ledger" ? null : reserved;
1276
+ }
1277
+ route.buffered.push({ chunk, token });
1278
+ route.bufferedChars += chunk.length;
1279
+ }
1280
+ // Route one duplex channel exit notification to the wait. Resolve only while
1281
+ // the route is `open` and the notification carries the exact bound worker
1282
+ // session identifier.
1283
+ function routeDuplexChannelExit(notification) {
1284
+ const params = isRecord(notification.params) ? notification.params : {};
1285
+ const hostRouteId = readNonEmptyString(params.hostRouteId);
1286
+ const workerSessionId = readNonEmptyString(params.workerSessionId);
1287
+ const resolved = resolveDuplexRouteByPair(hostRouteId, workerSessionId);
1288
+ if (resolved === "tombstoned") {
1289
+ // A late exit for a closed pair. It reaches no wait and changes no state.
1290
+ return;
1291
+ }
1292
+ if (resolved === "violation") {
1293
+ // An unknown, foreign, duplicate, ambiguous, or malformed pair. Fail closed:
1294
+ // retire the worker.
1295
+ retireDuplexWorkerOnViolation("exit frame pair");
1296
+ return;
1297
+ }
1298
+ if (resolved === "opening") {
1299
+ // The exit arrived before the bind. Hold it; the bind replays it through the
1300
+ // exact-pair routing.
1301
+ bufferPreBindDuplexFrame(hostRouteId, notification);
1302
+ return;
1303
+ }
1304
+ const exitCode = typeof params.exitCode === "number" ? params.exitCode : null;
1305
+ // A reason-less transport close carries `transportClosed`; a real process exit
1306
+ // does not. Carry the discriminator to the wait only when it is a transport
1307
+ // close, so a real exit keeps the plain `{ exitCode }` result and the broker
1308
+ // still keeps the two apart in the loss taxonomy.
1309
+ if (params.transportClosed === true) {
1310
+ settleRouteWait(resolved, { exitCode, transportClosed: true });
1311
+ return;
1312
+ }
1313
+ settleRouteWait(resolved, { exitCode });
1314
+ }
1315
+ // Hold one frame that arrived before its route bound, in order. The bind replays
1316
+ // the held frames through the exact-pair routing. An exit frame never consumes a
1317
+ // data hold slot; the host holds it in the single `preBindExit` slot instead, so
1318
+ // a worker that batches an exit among enough data frames to fill the hold cannot
1319
+ // crowd out a data frame. The data hold ceiling stays one frame above the
1320
+ // buffered bound, so the replay's buffered-bound check, not the hold, ends the
1321
+ // route. The host counts one protocol error for each data frame past the ceiling,
1322
+ // so an early flood bounds the hold instead of growing without limit.
1323
+ function bufferPreBindDuplexFrame(hostRouteId, notification) {
1324
+ if (!hostRouteId)
1325
+ return;
1326
+ const route = openingDuplexRoutes.get(hostRouteId);
1327
+ if (!route)
1328
+ return;
1329
+ const params = isRecord(notification.params) ? notification.params : {};
1330
+ const workerSessionId = readNonEmptyString(params.workerSessionId);
1331
+ if (!workerSessionId) {
1332
+ // A malformed pair. Count one per-route protocol error and hold nothing.
1333
+ recordDuplexChannelProtocolError(route);
1334
+ return;
1335
+ }
1336
+ if (notification.method === DUPLEX_CHANNEL_EXIT_NOTIFICATION) {
1337
+ // Normalize the exit to the narrow duplex-event schema. An exit retains only
1338
+ // a bounded scalar, so reserve a zero-byte token that carries the record
1339
+ // through the one cleanup path. Release any earlier held exit token first, so
1340
+ // a replaced exit never leaks its reservation.
1341
+ const exitCode = typeof params.exitCode === "number" ? params.exitCode : null;
1342
+ if (route.preBindExit)
1343
+ releaseRouteToken(route, route.preBindExit.token);
1344
+ const reserved = reserveRouteBytes(route, "pre_bind_event", 0);
1345
+ if (reserved === null) {
1346
+ // The aggregate ceiling rejected the reservation. Fail closed.
1347
+ void terminalizeDuplexChannelRoute(route);
1348
+ return;
1349
+ }
1350
+ route.preBindExit = {
1351
+ workerSessionId,
1352
+ exitCode,
1353
+ token: reserved === "no-ledger" ? null : reserved,
1354
+ };
1355
+ return;
1356
+ }
1357
+ // A data event. Validate and normalize it to the narrow duplex-event schema
1358
+ // before any retention. The wire carries the chunk as base64; decode it back
1359
+ // to raw bytes before any bound check or retention.
1360
+ const chunk = decodeChannelBytes(params.chunk);
1361
+ if (chunk === null || chunk.byteLength === 0) {
1362
+ recordDuplexChannelProtocolError(route);
1363
+ return;
1364
+ }
1365
+ if (route.preBind.length >= maxDuplexChannelPreBindHoldFrames) {
1366
+ recordDuplexChannelProtocolError(route);
1367
+ return;
1368
+ }
1369
+ // Reserve the exact retained raw byte count before the host holds the event.
1370
+ const reserved = reserveRouteBytes(route, "pre_bind_event", chunk.byteLength);
1371
+ if (reserved === null) {
1372
+ // The aggregate ceiling rejected the reservation. The caller retains nothing
1373
+ // and the route fails closed with the fixed marker.
1374
+ log.warn({ pluginId, reason: DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED }, "duplex pre-bind hold rejected");
1375
+ void terminalizeDuplexChannelRoute(route);
1376
+ return;
1377
+ }
1378
+ route.preBind.push({
1379
+ workerSessionId,
1380
+ chunk,
1381
+ token: reserved === "no-ledger" ? null : reserved,
1382
+ });
1383
+ }
1384
+ // Replay the frames a route held before it bound. The route is live now, so the
1385
+ // exact-pair routing delivers each held frame or fails closed on a mismatch.
1386
+ // Replay the held data frames first, in order, then the held exit last, so the
1387
+ // data delivers before the exit resolves the wait, which matches a real worker's
1388
+ // order. A frame that ends the route terminalizes it, and every later frame in
1389
+ // the replay is a no-op, because the routing functions drop a frame for a route
1390
+ // that is not `open`.
1391
+ function replayPreBindDuplexFrames(route) {
1392
+ const held = route.preBind;
1393
+ route.preBind = [];
1394
+ for (const event of held) {
1395
+ if (route.terminalized) {
1396
+ // The route ended mid-replay. Release the remaining held tokens, so the
1397
+ // unreplayed events retain nothing.
1398
+ releaseRouteToken(route, event.token);
1399
+ continue;
1400
+ }
1401
+ // Reconstruct a transient data notification for the exact-pair routing. The
1402
+ // host holds only the bounded event, so it builds this notification for the
1403
+ // routing step and discards it at once. The carried token moves to the
1404
+ // buffered representation without a decrement or a re-reserve.
1405
+ routeDuplexChannelData({
1406
+ jsonrpc: "2.0",
1407
+ method: DUPLEX_CHANNEL_DATA_NOTIFICATION,
1408
+ params: {
1409
+ hostRouteId: route.hostRouteId,
1410
+ workerSessionId: event.workerSessionId,
1411
+ chunk: event.chunk,
1412
+ },
1413
+ }, { token: event.token });
1414
+ }
1415
+ const heldExit = route.preBindExit;
1416
+ route.preBindExit = null;
1417
+ if (heldExit) {
1418
+ // The exit retains only a bounded scalar. Release its placeholder token, then
1419
+ // resolve the wait through the exact-pair routing when the route still lives.
1420
+ releaseRouteToken(route, heldExit.token);
1421
+ if (!route.terminalized) {
1422
+ routeDuplexChannelExit({
1423
+ jsonrpc: "2.0",
1424
+ method: DUPLEX_CHANNEL_EXIT_NOTIFICATION,
1425
+ params: {
1426
+ hostRouteId: route.hostRouteId,
1427
+ workerSessionId: heldExit.workerSessionId,
1428
+ exitCode: heldExit.exitCode,
1429
+ },
1430
+ });
1431
+ }
1432
+ }
1433
+ }
1434
+ // Close every route on a worker exit. The worker is gone, so the manager
1435
+ // resolves each wait with the fixed non-secret exit, releases each aggregate
1436
+ // slot, and clears the live, opening, and tombstone state. A worker exit is a
1437
+ // worker retirement, so the host drops every tombstone; the monotonic host route
1438
+ // id never returns, so no closed pair can revive on a restart. The pending
1439
+ // channel calls reject through `rejectAllPending`.
1440
+ function closeDuplexChannelRouteOnWorkerExit() {
1441
+ // Enumerate the opening index, the live index, and the terminal registry. A
1442
+ // terminalized route left the opening and live maps but may still hold buffered
1443
+ // bytes for a late listener; the worker is gone, so the host releases them now.
1444
+ const routes = [
1445
+ ...openingDuplexRoutes.values(),
1446
+ ...liveDuplexRoutes.values(),
1447
+ ...terminalDuplexRoutes,
1448
+ ];
1449
+ openingDuplexRoutes.clear();
1450
+ liveDuplexRoutes.clear();
1451
+ duplexPairTombstones.clear();
1452
+ for (const route of routes) {
1453
+ if (!route.terminalized) {
1454
+ route.terminalized = true;
1455
+ route.state = "closed";
1456
+ route.listener = null;
1457
+ route.bufferedChars = 0;
1458
+ clearDuplexChannelLifetimeTimer(route);
1459
+ releaseDuplexRouteSlot(route);
1460
+ settleRouteWait(route, { exitCode: null });
1461
+ }
1462
+ // Release every token the route still holds and clear its retained
1463
+ // representations, exactly once. A route already drained by a late listener
1464
+ // holds no token, so this is harmless and leaves the ledger at zero.
1465
+ discardRouteRetained(route);
1466
+ }
1467
+ // Clear the terminal registry. Every route in it was just discarded above.
1468
+ terminalDuplexRoutes.clear();
1469
+ }
1470
+ // Open one live generic duplex channel route. Reserve the route before the open
1471
+ // call, bind the worker session identifier one time on the first successful
1472
+ // open reply, and return a session a caller drives. Terminalize the route on
1473
+ // every open failure path.
1474
+ async function openDuplexChannel(input) {
1475
+ const hostRouteId = nextDuplexHostRouteId();
1476
+ let settleWait = () => { };
1477
+ const waitPromise = new Promise((resolve) => {
1478
+ settleWait = resolve;
1479
+ });
1480
+ const route = {
1481
+ hostRouteId,
1482
+ state: "reserved",
1483
+ workerSessionId: null,
1484
+ listener: null,
1485
+ buffered: [],
1486
+ bufferedChars: 0,
1487
+ pendingRequests: 0,
1488
+ protocolErrors: 0,
1489
+ totalDataBytes: 0,
1490
+ lifetimeTimer: null,
1491
+ terminalized: false,
1492
+ settleWait,
1493
+ preBind: [],
1494
+ preBindExit: null,
1495
+ retainedTokens: new Set(),
1496
+ };
1497
+ // Reserve one aggregate route slot before any work. When the process-scoped
1498
+ // ceiling is full, reject with the fixed route-busy error and open nothing, so
1499
+ // an active channel never downgrades and the ceiling never overcommits.
1500
+ if (!acquireDuplexRouteSlot(route)) {
1501
+ throw new Error(DUPLEX_CHANNEL_ROUTE_BUSY);
1502
+ }
1503
+ openingDuplexRoutes.set(hostRouteId, route);
1504
+ route.state = "opening";
1505
+ let openResult;
1506
+ try {
1507
+ openResult = await callInternal("duplexChannelOpen", {
1508
+ hostRouteId,
1509
+ driverKey: input.driverKey,
1510
+ companyId: input.companyId,
1511
+ environmentId: input.environmentId,
1512
+ providerLeaseId: input.providerLeaseId,
1513
+ command: input.command,
1514
+ }, duplexChannelOpenTimeoutMs);
1515
+ }
1516
+ catch (err) {
1517
+ // A send failure, an RPC rejection, or an open timeout. Terminalize the
1518
+ // route exactly once and fail closed.
1519
+ await terminalizeDuplexChannelRoute(route);
1520
+ throw err instanceof Error ? err : new Error(DUPLEX_CHANNEL_OPEN_FAILED);
1521
+ }
1522
+ const workerSessionId = readBindableWorkerSessionId(route, openResult);
1523
+ // Verify the worker echoed the exact host route id the open request carried.
1524
+ // A reply with a missing or a mismatched host route id never binds, so the
1525
+ // host binds only a reply that proves the worker holds the exact pair.
1526
+ const echoedHostRouteId = isRecord(openResult)
1527
+ ? readNonEmptyString(openResult.hostRouteId)
1528
+ : null;
1529
+ if (!workerSessionId || echoedHostRouteId !== hostRouteId) {
1530
+ // A malformed reply, a mismatched host route id, or a route that already
1531
+ // left `opening`. A late or a duplicate reply never binds, revives, or
1532
+ // reopens a route.
1533
+ await terminalizeDuplexChannelRoute(route);
1534
+ throw new Error(DUPLEX_CHANNEL_OPEN_FAILED);
1535
+ }
1536
+ const pairKey = duplexPairKey(hostRouteId, workerSessionId);
1537
+ if (liveDuplexRoutes.has(pairKey) || duplexPairTombstones.has(pairKey)) {
1538
+ // The worker returned a pair that is already live or already tombstoned. Fail
1539
+ // closed: terminalize this route and retire the worker before any reuse.
1540
+ await terminalizeDuplexChannelRoute(route);
1541
+ retireDuplexWorkerOnViolation("duplicate bound pair");
1542
+ throw new Error(DUPLEX_CHANNEL_OPEN_FAILED);
1543
+ }
1544
+ // Bind the worker session identifier one time and move the route from the
1545
+ // opening map to the live map under the exact pair key.
1546
+ route.workerSessionId = workerSessionId;
1547
+ route.state = "open";
1548
+ openingDuplexRoutes.delete(hostRouteId);
1549
+ liveDuplexRoutes.set(pairKey, route);
1550
+ // Start the route lifetime timer now the route is open. The route ends when
1551
+ // the timer expires. Every terminal path and the worker-exit path clears the
1552
+ // timer. Unreference the timer so it never blocks the host process shutdown.
1553
+ route.lifetimeTimer = setTimeout(() => {
1554
+ void terminalizeDuplexChannelRoute(route);
1555
+ }, maxDuplexChannelDurationMs);
1556
+ route.lifetimeTimer.unref?.();
1557
+ // Replay any frame that arrived before the bind. The route is live now, so the
1558
+ // exact-pair routing delivers each held frame or fails closed on a mismatch.
1559
+ replayPreBindDuplexFrames(route);
1560
+ // Send one host→worker request under the pending-request bound. End the route
1561
+ // when too many requests are in-flight, so a worker that never replies cannot
1562
+ // make the host hold an unbounded number of pending requests.
1563
+ const sendBoundedRequest = (method, params,
1564
+ // The exact raw byte count of a `duplexChannelWrite` payload, before base64
1565
+ // encoding. The caller supplies it: `params.data` on the wire is the base64
1566
+ // form (`ChannelBytesWireValue`), so its string length no longer equals the
1567
+ // real payload bytes. A stop request carries no payload and omits this.
1568
+ writeByteCount) => {
1569
+ if (route.state !== "open")
1570
+ return;
1571
+ if (route.pendingRequests >= maxDuplexChannelPendingRequests) {
1572
+ void terminalizeDuplexChannelRoute(route);
1573
+ return;
1574
+ }
1575
+ // Reserve the exact raw byte count of a host→worker write against the
1576
+ // aggregate ledger before `callInternal` retains the payload. A pending write
1577
+ // RPC holds `params.data` until it settles, so this reservation bounds the
1578
+ // aggregate host→worker pending-write bytes across every route. A stop
1579
+ // request carries no payload, so it reserves nothing. When no ledger is
1580
+ // present, admit the write with no token (a unit test constructs the handle
1581
+ // this way).
1582
+ let pendingWriteToken = null;
1583
+ if (method === "duplexChannelWrite" && duplexAggregateByteLedger) {
1584
+ pendingWriteToken = duplexAggregateByteLedger.reserve("pending_write", writeByteCount ?? 0);
1585
+ if (!pendingWriteToken) {
1586
+ // The reservation would pass the aggregate ceiling. Retain nothing, do
1587
+ // not enqueue the RPC, and end the route fail-closed with the aggregate
1588
+ // marker, not the route-busy marker.
1589
+ log.warn({ pluginId, reason: DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED }, "duplex pending write reservation rejected");
1590
+ void terminalizeDuplexChannelRoute(route);
1591
+ return;
1592
+ }
1593
+ }
1594
+ route.pendingRequests += 1;
1595
+ // Meter the child-stdin transport buffer only for a duplex write. A stop
1596
+ // request carries a tiny fixed frame that the host never lets grow, so it
1597
+ // does not meter or reject. The write path reserves the serialized frame in
1598
+ // `sendMessage` right before the stdin write.
1599
+ const meterDuplexWrite = method === "duplexChannelWrite";
1600
+ void callInternal(method, params, duplexChannelOpenTimeoutMs, undefined, meterDuplexWrite)
1601
+ .catch((err) => {
1602
+ if (err instanceof DuplexAggregateBytesExceededError) {
1603
+ // The transport reservation failed. The host did not write the frame.
1604
+ // End the route fail-closed with the aggregate marker, not the
1605
+ // route-busy marker.
1606
+ log.warn({ pluginId, reason: DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED }, "duplex stdin write reservation rejected");
1607
+ void terminalizeDuplexChannelRoute(route);
1608
+ }
1609
+ })
1610
+ .finally(() => {
1611
+ route.pendingRequests -= 1;
1612
+ // Release the pending-write token one time, after the RPC settles on any
1613
+ // path: success, error, timeout, worker exit, or shutdown. The token is
1614
+ // not in `route.retainedTokens`, so route terminalization never releases
1615
+ // it; only this settlement releases it. The separate `stdin_write` token
1616
+ // covers the serialized frame and releases on the stream flush, the
1617
+ // stream error, the stream close, or the worker exit, never here.
1618
+ if (pendingWriteToken) {
1619
+ duplexAggregateByteLedger?.release(pendingWriteToken);
1620
+ }
1621
+ });
1622
+ };
1623
+ return {
1624
+ onData(listener) {
1625
+ route.listener = listener;
1626
+ if (route.buffered.length > 0) {
1627
+ const pending = route.buffered;
1628
+ route.buffered = [];
1629
+ route.bufferedChars = 0;
1630
+ for (const record of pending) {
1631
+ // Deliver each buffered record, drop its retained buffer, and release
1632
+ // its exact token before the drain proceeds to the next record. A
1633
+ // terminal route drains through this same code.
1634
+ deliverDuplexChannelChunk(listener, record.chunk);
1635
+ releaseRouteToken(route, record.token);
1636
+ }
1637
+ // The buffered records are gone, so the route no longer holds terminal
1638
+ // bytes for a late listener.
1639
+ terminalDuplexRoutes.delete(route);
1640
+ }
1641
+ },
1642
+ write(data) {
1643
+ const sid = route.workerSessionId;
1644
+ if (route.state !== "open" || !sid)
1645
+ return;
1646
+ if (data.byteLength > maxDuplexChannelWriteChars) {
1647
+ // The write is larger than the size bound. End the route before the
1648
+ // write reaches the worker.
1649
+ void terminalizeDuplexChannelRoute(route);
1650
+ return;
1651
+ }
1652
+ // Encode the raw bytes to the wire-safe base64 form (JSON carries no
1653
+ // binary type) and pass the exact raw byte count separately, so the
1654
+ // pending-write ledger reservation charges the real payload bytes, not
1655
+ // the inflated base64 string length.
1656
+ sendBoundedRequest("duplexChannelWrite", {
1657
+ hostRouteId: route.hostRouteId,
1658
+ workerSessionId: sid,
1659
+ data: encodeChannelBytes(data),
1660
+ }, data.byteLength);
1661
+ },
1662
+ wait() {
1663
+ return waitPromise;
1664
+ },
1665
+ kill() {
1666
+ const sid = route.workerSessionId;
1667
+ if (!sid)
1668
+ return;
1669
+ sendBoundedRequest("duplexChannelStop", { hostRouteId: route.hostRouteId, workerSessionId: sid });
1670
+ },
1671
+ async close() {
1672
+ await terminalizeDuplexChannelRoute(route);
702
1673
  },
703
1674
  };
704
1675
  }
@@ -840,15 +1811,26 @@ export function createPluginWorkerHandle(pluginId, options) {
840
1811
  routeExecuteLogNotification(notification);
841
1812
  return;
842
1813
  }
843
- // Setup-token login pseudo-terminal notifications: deliver output
1814
+ // Login pseudo-terminal notifications: deliver output
844
1815
  // and the exit to the one host-owned login route, bound by the worker session
845
1816
  // identifier while the route is open.
846
- if (notification.method === SETUP_TOKEN_PTY_OUTPUT_NOTIFICATION) {
847
- routeSetupTokenPtyOutput(notification);
1817
+ if (notification.method === LOGIN_PTY_OUTPUT_NOTIFICATION) {
1818
+ routeLoginPtyOutput(notification);
848
1819
  return;
849
1820
  }
850
- if (notification.method === SETUP_TOKEN_PTY_EXIT_NOTIFICATION) {
851
- routeSetupTokenPtyExit(notification);
1821
+ if (notification.method === LOGIN_PTY_EXIT_NOTIFICATION) {
1822
+ routeLoginPtyExit(notification);
1823
+ return;
1824
+ }
1825
+ // Duplex channel notifications: deliver data and the exit to the one
1826
+ // host-owned duplex route, bound by the worker session identifier while the
1827
+ // route is open.
1828
+ if (notification.method === DUPLEX_CHANNEL_DATA_NOTIFICATION) {
1829
+ routeDuplexChannelData(notification);
1830
+ return;
1831
+ }
1832
+ if (notification.method === DUPLEX_CHANNEL_EXIT_NOTIFICATION) {
1833
+ routeDuplexChannelExit(notification);
852
1834
  return;
853
1835
  }
854
1836
  // Stream notifications: forward to the stream bus via callback
@@ -921,6 +1903,17 @@ export function createPluginWorkerHandle(pluginId, options) {
921
1903
  readline = createInterface({ input: child.stdout });
922
1904
  readline.on("line", handleLine);
923
1905
  }
1906
+ // Release the outstanding child-stdin transport tokens when the stdin stream
1907
+ // errors or closes, because each discards the stdin write buffer. The `error`
1908
+ // listener also stops an unhandled EPIPE from a child that closed its stdin.
1909
+ if (child.stdin) {
1910
+ child.stdin.on("error", () => {
1911
+ releaseAllPendingStdinWriteTokens();
1912
+ });
1913
+ child.stdin.on("close", () => {
1914
+ releaseAllPendingStdinWriteTokens();
1915
+ });
1916
+ }
924
1917
  // Capture stderr for logging
925
1918
  if (child.stderr) {
926
1919
  stderrReadline = createInterface({ input: child.stderr });
@@ -958,12 +1951,20 @@ export function createPluginWorkerHandle(pluginId, options) {
958
1951
  }
959
1952
  childProcess = null;
960
1953
  startedAt = null;
1954
+ // The worker exit discards the child-stdin write buffer, so release every
1955
+ // outstanding transport token. The RPC rejections below never release these
1956
+ // tokens; only this sweep, a stream flush, a stream error, or a stream close
1957
+ // releases them.
1958
+ releaseAllPendingStdinWriteTokens();
961
1959
  // Reject all pending requests
962
1960
  rejectAllPending(new Error(formatWorkerFailureMessage(`Worker process exited (code=${code}, signal=${signal})`, stderrExcerpt)));
963
1961
  // Close the one login pseudo-terminal route with a fixed non-secret exit and
964
1962
  // clear the route one time. The pending pseudo-terminal calls
965
1963
  // already rejected through `rejectAllPending`.
966
- closeSetupTokenPtyRouteOnWorkerExit();
1964
+ closeLoginPtyRouteOnWorkerExit();
1965
+ // Close the one duplex channel route the same way. The pending channel calls
1966
+ // already rejected through `rejectAllPending`.
1967
+ closeDuplexChannelRouteOnWorkerExit();
967
1968
  // Emit synthetic close for any orphaned stream channels so SSE clients
968
1969
  // are notified instead of hanging indefinitely.
969
1970
  if (openStreamChannels.size > 0 && options.onStreamNotification) {
@@ -1216,7 +2217,7 @@ export function createPluginWorkerHandle(pluginId, options) {
1216
2217
  // -----------------------------------------------------------------------
1217
2218
  // RPC call implementation
1218
2219
  // -----------------------------------------------------------------------
1219
- function callInternal(method, params, timeoutMs, executeLogSink) {
2220
+ function callInternal(method, params, timeoutMs, executeLogSink, meterDuplexWrite = false) {
1220
2221
  const rpcPromise = new Promise((resolve, reject) => {
1221
2222
  if (!childProcess?.stdin?.writable) {
1222
2223
  reject(new Error(`Cannot call "${method}" — worker for "${pluginId}" is not running`));
@@ -1278,14 +2279,22 @@ export function createPluginWorkerHandle(pluginId, options) {
1278
2279
  ...createRequest(method, params, id),
1279
2280
  ...(invocation ? { paperclipInvocation: invocation } : {}),
1280
2281
  };
1281
- sendMessage(request);
2282
+ sendMessage(request, meterDuplexWrite);
1282
2283
  }
1283
2284
  catch (err) {
1284
2285
  clearTimeout(timer);
1285
2286
  pendingRequests.delete(id);
1286
2287
  clearInvocation(invocation);
1287
2288
  clearExecuteRoute(invocation?.id);
1288
- reject(new Error(`Failed to send "${method}" to worker: ${err instanceof Error ? err.message : String(err)}`));
2289
+ if (err instanceof DuplexAggregateBytesExceededError) {
2290
+ // The transport reservation failed before the write. Reject with the
2291
+ // typed error unwrapped, so the duplex write caller ends the route
2292
+ // fail-closed with the aggregate marker.
2293
+ reject(err);
2294
+ }
2295
+ else {
2296
+ reject(new Error(`Failed to send "${method}" to worker: ${err instanceof Error ? err.message : String(err)}`));
2297
+ }
1289
2298
  }
1290
2299
  });
1291
2300
  // Some call sites hand these promises across async boundaries before
@@ -1324,11 +2333,17 @@ export function createPluginWorkerHandle(pluginId, options) {
1324
2333
  }
1325
2334
  return callInternal(method, params, timeoutMs, executeLogSink);
1326
2335
  },
1327
- openSetupTokenPtySession(input) {
2336
+ openLoginPtySession(input) {
1328
2337
  if (status !== "running" && status !== "starting") {
1329
2338
  return Promise.reject(new Error(`Cannot open a login pseudo-terminal — worker for "${pluginId}" is ${status}`));
1330
2339
  }
1331
- return openSetupTokenPtySession(input);
2340
+ return openLoginPtySession(input);
2341
+ },
2342
+ openDuplexChannel(input) {
2343
+ if (status !== "running" && status !== "starting") {
2344
+ return Promise.reject(new Error(`Cannot open a duplex channel — worker for "${pluginId}" is ${status}`));
2345
+ }
2346
+ return openDuplexChannel(input);
1332
2347
  },
1333
2348
  notify(method, params) {
1334
2349
  if (status !== "running")
@@ -1383,6 +2398,35 @@ export function createPluginWorkerHandle(pluginId, options) {
1383
2398
  };
1384
2399
  return handle;
1385
2400
  }
2401
+ /**
2402
+ * The default process-scoped aggregate ceiling for concurrent duplex channel
2403
+ * routes. It caps the manager-wide resource, not one agent's run budget. The host
2404
+ * reports an explicit route-busy outcome when the ceiling is full.
2405
+ */
2406
+ export const DEFAULT_MAX_CONCURRENT_DUPLEX_ROUTES = 128;
2407
+ /**
2408
+ * Build one process-scoped aggregate route-slot controller. The controller holds a
2409
+ * strictly positive integer ceiling and a live count. `tryAcquire` reserves one
2410
+ * slot only when a slot is free, so the count never passes the ceiling.
2411
+ */
2412
+ export function createDuplexRouteSlotController(maxRoutes) {
2413
+ const ceiling = typeof maxRoutes === "number" && Number.isInteger(maxRoutes) && maxRoutes > 0
2414
+ ? maxRoutes
2415
+ : DEFAULT_MAX_CONCURRENT_DUPLEX_ROUTES;
2416
+ let active = 0;
2417
+ return {
2418
+ tryAcquire() {
2419
+ if (active >= ceiling)
2420
+ return false;
2421
+ active += 1;
2422
+ return true;
2423
+ },
2424
+ release() {
2425
+ if (active > 0)
2426
+ active -= 1;
2427
+ },
2428
+ };
2429
+ }
1386
2430
  /**
1387
2431
  * Create a new PluginWorkerManager.
1388
2432
  *
@@ -1414,6 +2458,15 @@ export function createPluginWorkerManager(managerOptions) {
1414
2458
  const workers = new Map();
1415
2459
  /** Per-plugin startup locks to prevent concurrent spawn races. */
1416
2460
  const startupLocks = new Map();
2461
+ // The one shared, process-scoped aggregate route-slot controller. The manager
2462
+ // injects it into every worker handle, so the duplex route ceiling counts every
2463
+ // concurrent route across the process, not one agent's setting.
2464
+ const duplexRouteSlots = createDuplexRouteSlotController(managerOptions?.maxConcurrentDuplexRoutes);
2465
+ // The one shared, process-owned aggregate byte ledger. The manager injects the
2466
+ // same object into every worker handle, so one gauge bounds the aggregate
2467
+ // retained bytes across every route in the process. It is `null` when the
2468
+ // process root injected no ledger.
2469
+ const duplexAggregateByteLedger = managerOptions?.duplexAggregateByteLedger ?? null;
1417
2470
  return {
1418
2471
  async startWorker(pluginId, options) {
1419
2472
  // Mutex: if a start is already in-flight for this plugin, wait for it
@@ -1426,7 +2479,14 @@ export function createPluginWorkerManager(managerOptions) {
1426
2479
  if (existing && existing.status !== "stopped") {
1427
2480
  throw new Error(`Worker already registered for plugin "${pluginId}" (status: ${existing.status})`);
1428
2481
  }
1429
- const handle = createPluginWorkerHandle(pluginId, options);
2482
+ const handle = createPluginWorkerHandle(pluginId, {
2483
+ // Inject the shared process-scoped route-slot controller and the shared
2484
+ // process-owned aggregate byte ledger, unless the caller already supplied
2485
+ // its own (a test may inject its own).
2486
+ duplexRouteSlots,
2487
+ duplexAggregateByteLedger,
2488
+ ...options,
2489
+ });
1430
2490
  workers.set(pluginId, handle);
1431
2491
  // Subscribe to crash/ready events for live event forwarding
1432
2492
  if (managerOptions?.onWorkerEvent) {
@@ -1505,12 +2565,12 @@ export function createPluginWorkerManager(managerOptions) {
1505
2565
  }
1506
2566
  return handle.call(method, params, timeoutMs, executeLogSink);
1507
2567
  },
1508
- openSetupTokenPtySession(pluginId, input) {
2568
+ openLoginPtySession(pluginId, input) {
1509
2569
  const handle = workers.get(pluginId);
1510
2570
  if (!handle) {
1511
2571
  return Promise.reject(new Error(`No worker registered for plugin "${pluginId}"`));
1512
2572
  }
1513
- return handle.openSetupTokenPtySession(input);
2573
+ return handle.openLoginPtySession(input);
1514
2574
  },
1515
2575
  };
1516
2576
  }