pi-central 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (273) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +204 -2
  3. package/bin/pi-central.js +2836 -0
  4. package/bin/pi-central.js.map +7 -0
  5. package/dist/ui/assets/1c-CM5UDbeN.js +1 -0
  6. package/dist/ui/assets/1c-query-DVSXgUoE.js +1 -0
  7. package/dist/ui/assets/abap-BdImnpbu.js +1 -0
  8. package/dist/ui/assets/ada-BIOnG6TC.js +1 -0
  9. package/dist/ui/assets/adoc-DYxrpSh4.js +1 -0
  10. package/dist/ui/assets/ahk1-D2f7Ijez.js +1 -0
  11. package/dist/ui/assets/ahk2-8Zs4aa1G.js +1 -0
  12. package/dist/ui/assets/angular-expression-C9JlwiXB.js +1 -0
  13. package/dist/ui/assets/angular-html-D2F1cIM9.js +1 -0
  14. package/dist/ui/assets/angular-inline-style-aI-YlMgr.js +1 -0
  15. package/dist/ui/assets/angular-inline-template-BhVWr-GB.js +1 -0
  16. package/dist/ui/assets/angular-let-declaration-tXF0G55Y.js +1 -0
  17. package/dist/ui/assets/angular-template-PU1Sa1kY.js +1 -0
  18. package/dist/ui/assets/angular-template-blocks-D4Ew2WDq.js +1 -0
  19. package/dist/ui/assets/angular-ts-B5vx3S6V.js +1 -0
  20. package/dist/ui/assets/apache-Pmp26Uib.js +1 -0
  21. package/dist/ui/assets/apex-DhZFqWV2.js +1 -0
  22. package/dist/ui/assets/apl-BD0Fkm--.js +1 -0
  23. package/dist/ui/assets/applescript-Co6uUVPk.js +1 -0
  24. package/dist/ui/assets/ara-BRHolxvo.js +1 -0
  25. package/dist/ui/assets/as3-CDRFp3-Q.js +1 -0
  26. package/dist/ui/assets/asm-D_Q5rh1f.js +1 -0
  27. package/dist/ui/assets/astro-CeWFm1CR.js +1 -0
  28. package/dist/ui/assets/awk-DMzUqQB5.js +1 -0
  29. package/dist/ui/assets/ballerina-BFfxhgS-.js +1 -0
  30. package/dist/ui/assets/be-DZEKi0sx.js +1 -0
  31. package/dist/ui/assets/beancount-BgSHGh8x.js +1 -0
  32. package/dist/ui/assets/bibtex-CHM0blh-.js +1 -0
  33. package/dist/ui/assets/bicep-Bmn6On1c.js +1 -0
  34. package/dist/ui/assets/bird-CeWWThpZ.js +1 -0
  35. package/dist/ui/assets/blade-CQs288pm.js +1 -0
  36. package/dist/ui/assets/c-BIGW1oBm.js +1 -0
  37. package/dist/ui/assets/c3-Dp5svz6Z.js +1 -0
  38. package/dist/ui/assets/cairo-Ba9TFftT.js +1 -0
  39. package/dist/ui/assets/cdc-Dyf7ylpP.js +1 -0
  40. package/dist/ui/assets/chpl-QDWX03Hx.js +1 -0
  41. package/dist/ui/assets/clarity-Dn5IMItf.js +1 -0
  42. package/dist/ui/assets/clj-CZPsrr31.js +1 -0
  43. package/dist/ui/assets/closure-templates-Cv0TS44c.js +1 -0
  44. package/dist/ui/assets/cmake-D1j8_8rp.js +1 -0
  45. package/dist/ui/assets/cmd-T6t9F4r8.js +1 -0
  46. package/dist/ui/assets/cobol-D6oRQjbg.js +1 -0
  47. package/dist/ui/assets/codeowners-Bp6g37R7.js +1 -0
  48. package/dist/ui/assets/coffeescript-l2w2Enha.js +1 -0
  49. package/dist/ui/assets/console-JZr4tX8D.js +1 -0
  50. package/dist/ui/assets/coq-BqXF5kFF.js +1 -0
  51. package/dist/ui/assets/core-CcaQSRDK.js +12 -0
  52. package/dist/ui/assets/cpp-2YYZ9QPt.js +1 -0
  53. package/dist/ui/assets/cpp-macro-BD_u8FP2.js +1 -0
  54. package/dist/ui/assets/cql-BEVj694s.js +1 -0
  55. package/dist/ui/assets/crystal-HlmihOZ-.js +1 -0
  56. package/dist/ui/assets/cs-CYZm7ub4.js +1 -0
  57. package/dist/ui/assets/css-Z72CF80r.js +1 -0
  58. package/dist/ui/assets/csv-fuZLfV_i.js +1 -0
  59. package/dist/ui/assets/cue-D82EKSYY.js +1 -0
  60. package/dist/ui/assets/d-85-TOEBH.js +1 -0
  61. package/dist/ui/assets/dart-CtKP3W-j.js +1 -0
  62. package/dist/ui/assets/dax-CEL-wOlO.js +1 -0
  63. package/dist/ui/assets/desktop-BmXAJ9_W.js +1 -0
  64. package/dist/ui/assets/diff-D97Zzqfu.js +1 -0
  65. package/dist/ui/assets/dockerfile-D5eKlNGy.js +1 -0
  66. package/dist/ui/assets/dotenv-De0Btta0.js +1 -0
  67. package/dist/ui/assets/dream-maker-BtqSS_iP.js +1 -0
  68. package/dist/ui/assets/edge-BA25uoI8.js +1 -0
  69. package/dist/ui/assets/elisp-BQtZl9iK.js +1 -0
  70. package/dist/ui/assets/elixir-BzU7DNz_.js +1 -0
  71. package/dist/ui/assets/elm-DbKCFpqz.js +1 -0
  72. package/dist/ui/assets/engine-javascript-CuxHA8oH.js +141 -0
  73. package/dist/ui/assets/erb-B-_FUE16.js +1 -0
  74. package/dist/ui/assets/erl-btUkaAv0.js +1 -0
  75. package/dist/ui/assets/es-tag-css-Di8q07hx.js +1 -0
  76. package/dist/ui/assets/es-tag-glsl-DuY_gmju.js +1 -0
  77. package/dist/ui/assets/es-tag-html-CFifETgQ.js +1 -0
  78. package/dist/ui/assets/es-tag-sql-B4Gn1aEo.js +1 -0
  79. package/dist/ui/assets/es-tag-xml-BsowVsUr.js +1 -0
  80. package/dist/ui/assets/f95-OKqbxTCS.js +1 -0
  81. package/dist/ui/assets/fennel-BYunw83y.js +1 -0
  82. package/dist/ui/assets/fish-BvzEVeQv.js +1 -0
  83. package/dist/ui/assets/for-TD-2-mvr.js +1 -0
  84. package/dist/ui/assets/fs-2PIK3WrC.js +1 -0
  85. package/dist/ui/assets/fsl-DKWnMB5p.js +1 -0
  86. package/dist/ui/assets/ftl-anfqXHis.js +1 -0
  87. package/dist/ui/assets/gd-DmgsuTnj.js +1 -0
  88. package/dist/ui/assets/gdshader-DkwncUOv.js +1 -0
  89. package/dist/ui/assets/genie-D0YGMca9.js +1 -0
  90. package/dist/ui/assets/gherkin-DyxjwDmM.js +1 -0
  91. package/dist/ui/assets/git-commit-F4YmCXRG.js +1 -0
  92. package/dist/ui/assets/git-rebase-Bqs5jo_V.js +1 -0
  93. package/dist/ui/assets/github-dark-default-Cuk6v7N8.js +1 -0
  94. package/dist/ui/assets/gjs-gYW9DBuS.js +1 -0
  95. package/dist/ui/assets/gleam-BspZqrRM.js +1 -0
  96. package/dist/ui/assets/glsl-DplSGwfg.js +1 -0
  97. package/dist/ui/assets/gn-8iBmfbXw.js +1 -0
  98. package/dist/ui/assets/gnuplot-DdkO51Og.js +1 -0
  99. package/dist/ui/assets/go-C27-OAKa.js +1 -0
  100. package/dist/ui/assets/gql-MGiqm7Pm.js +1 -0
  101. package/dist/ui/assets/grammar-map-VuoEIDon.js +2 -0
  102. package/dist/ui/assets/groovy-gcz8RCvz.js +1 -0
  103. package/dist/ui/assets/gts-DqU1Me4G.js +1 -0
  104. package/dist/ui/assets/hack-V6hhs-tI.js +1 -0
  105. package/dist/ui/assets/haml-jtrXxllQ.js +1 -0
  106. package/dist/ui/assets/haxe-CfZj7gIn.js +1 -0
  107. package/dist/ui/assets/hbs-CsgKbzmm.js +1 -0
  108. package/dist/ui/assets/hcl-BWvSN4gD.js +1 -0
  109. package/dist/ui/assets/hjson-D5-asLiD.js +1 -0
  110. package/dist/ui/assets/hlsl-D3lLCCz7.js +1 -0
  111. package/dist/ui/assets/hs-cv9E3dfg.js +1 -0
  112. package/dist/ui/assets/html-derivative-zZehN5i_.js +1 -0
  113. package/dist/ui/assets/html-tMaWOgtj.js +1 -0
  114. package/dist/ui/assets/http-BS9aPmI9.js +1 -0
  115. package/dist/ui/assets/hurl-BxI2TCqe.js +1 -0
  116. package/dist/ui/assets/hxml-2-FPmUDs.js +1 -0
  117. package/dist/ui/assets/hy-DFXneXwc.js +1 -0
  118. package/dist/ui/assets/imba-DGztddWO.js +1 -0
  119. package/dist/ui/assets/index-BGiGoTsW.css +1 -0
  120. package/dist/ui/assets/index-BiOcGwMm.js +1 -0
  121. package/dist/ui/assets/index-qgPxqDeZ.js +40 -0
  122. package/dist/ui/assets/jade-CZYBTpjq.js +1 -0
  123. package/dist/ui/assets/java-CylS5w8V.js +1 -0
  124. package/dist/ui/assets/jinja-BV377aI7.js +1 -0
  125. package/dist/ui/assets/jinja-html-J_niBkwy.js +1 -0
  126. package/dist/ui/assets/jison-CI3IlJ66.js +1 -0
  127. package/dist/ui/assets/jl-DnT-B-wk.js +1 -0
  128. package/dist/ui/assets/json-Cp-IABpG.js +1 -0
  129. package/dist/ui/assets/json5-C9tS-k6U.js +1 -0
  130. package/dist/ui/assets/jsonc-Des-eS-w.js +1 -0
  131. package/dist/ui/assets/jsonl-DcaNXYhu.js +1 -0
  132. package/dist/ui/assets/jsonnet-DFQXde-d.js +1 -0
  133. package/dist/ui/assets/jsx-g9-lgVsj.js +1 -0
  134. package/dist/ui/assets/justfile-DM2KgpEt.js +1 -0
  135. package/dist/ui/assets/kdl-DV7GczEv.js +1 -0
  136. package/dist/ui/assets/kql-CAZwdKJH.js +1 -0
  137. package/dist/ui/assets/kts-DPNV9Nys.js +1 -0
  138. package/dist/ui/assets/latex-DJVikpzR.js +1 -0
  139. package/dist/ui/assets/lean4-CwwCO9Zr.js +1 -0
  140. package/dist/ui/assets/less-B1dDrJ26.js +1 -0
  141. package/dist/ui/assets/liquid-Bzms4plq.js +1 -0
  142. package/dist/ui/assets/lisp-d1OqKJ0v.js +1 -0
  143. package/dist/ui/assets/lit-C5SbI-X-.js +1 -0
  144. package/dist/ui/assets/llvm-BZoOZj88.js +1 -0
  145. package/dist/ui/assets/log-2UxHyX5q.js +1 -0
  146. package/dist/ui/assets/logo-BtOb2qkB.js +1 -0
  147. package/dist/ui/assets/lua-BaeVxFsk.js +1 -0
  148. package/dist/ui/assets/luau-nOZpfvQi.js +1 -0
  149. package/dist/ui/assets/makefile-BSLFislC.js +1 -0
  150. package/dist/ui/assets/markdown-nix-CTic1qqq.js +1 -0
  151. package/dist/ui/assets/markdown-vue-Cbnvtjg5.js +1 -0
  152. package/dist/ui/assets/marko-CPupNq7w.js +1 -0
  153. package/dist/ui/assets/matlab-D7o27uSR.js +1 -0
  154. package/dist/ui/assets/mbti-Myp4B69h.js +1 -0
  155. package/dist/ui/assets/md-Dt3v3XWf.js +1 -0
  156. package/dist/ui/assets/mdc-BlkeRyZp.js +1 -0
  157. package/dist/ui/assets/mdx-Cmh6b_Ma.js +1 -0
  158. package/dist/ui/assets/mips-D0hiF3sa.js +1 -0
  159. package/dist/ui/assets/mjs-BOef1GtE.js +1 -0
  160. package/dist/ui/assets/mmd-CtyOPoVJ.js +1 -0
  161. package/dist/ui/assets/mojo-DJz3ZmWd.js +1 -0
  162. package/dist/ui/assets/move-el3G9tDJ.js +1 -0
  163. package/dist/ui/assets/nar-CgJlRCyo.js +1 -0
  164. package/dist/ui/assets/nextflow-groovy-CedDknKX.js +1 -0
  165. package/dist/ui/assets/nf-BAJNoAAx.js +1 -0
  166. package/dist/ui/assets/nginx-BpAMiNFr.js +1 -0
  167. package/dist/ui/assets/nim-BCvQ9LSl.js +1 -0
  168. package/dist/ui/assets/nix-CNulEsOp.js +1 -0
  169. package/dist/ui/assets/nsis-BlV79W_Q.js +1 -0
  170. package/dist/ui/assets/nu-AJ1T3e-j.js +1 -0
  171. package/dist/ui/assets/objc-B7QIVcT6.js +1 -0
  172. package/dist/ui/assets/objective-cpp-CLxacb5B.js +1 -0
  173. package/dist/ui/assets/ocaml-C0hk2d4L.js +1 -0
  174. package/dist/ui/assets/odin-BBf5iR-q.js +1 -0
  175. package/dist/ui/assets/org-D8hinxRC.js +1 -0
  176. package/dist/ui/assets/pascal-D93ZcfNL.js +1 -0
  177. package/dist/ui/assets/perl-9_yHOIUr.js +1 -0
  178. package/dist/ui/assets/perl6-Czm8BUOa.js +1 -0
  179. package/dist/ui/assets/php-x4cDdYNa.js +1 -0
  180. package/dist/ui/assets/pkl-u5AG7uiY.js +1 -0
  181. package/dist/ui/assets/plsql-ChMvpjG-.js +1 -0
  182. package/dist/ui/assets/polar-C0HS_06l.js +1 -0
  183. package/dist/ui/assets/postcss-CXtECtnM.js +1 -0
  184. package/dist/ui/assets/potx-Zubr0-kU.js +1 -0
  185. package/dist/ui/assets/powerquery-CEu0bR-o.js +1 -0
  186. package/dist/ui/assets/prisma-Vru482bI.js +1 -0
  187. package/dist/ui/assets/prolog-CbFg5uaA.js +1 -0
  188. package/dist/ui/assets/properties-h29QGfEk.js +1 -0
  189. package/dist/ui/assets/protobuf-S0pP4B9O.js +1 -0
  190. package/dist/ui/assets/puppet-BMWR74SV.js +1 -0
  191. package/dist/ui/assets/purescript-CklMAg4u.js +1 -0
  192. package/dist/ui/assets/pwsh-BpVOxWsY.js +1 -0
  193. package/dist/ui/assets/py-DWrqmXJp.js +1 -0
  194. package/dist/ui/assets/ql-CuETlrBG.js +1 -0
  195. package/dist/ui/assets/qml-CAmqjwmT.js +1 -0
  196. package/dist/ui/assets/qmldir-C8lEn-DE.js +1 -0
  197. package/dist/ui/assets/qss-IeuSbFQv.js +1 -0
  198. package/dist/ui/assets/r-Cf5RLm7j.js +1 -0
  199. package/dist/ui/assets/racket-BqYA7rlc.js +1 -0
  200. package/dist/ui/assets/razor-CMtLDytp.js +1 -0
  201. package/dist/ui/assets/rb-COoUipX6.js +1 -0
  202. package/dist/ui/assets/reg-C-SQnVFl.js +1 -0
  203. package/dist/ui/assets/regex-4rjvoqip.js +1 -0
  204. package/dist/ui/assets/rel-C3B-1QV4.js +1 -0
  205. package/dist/ui/assets/riscv-BM1_JUlF.js +1 -0
  206. package/dist/ui/assets/ron-D8l8udqQ.js +1 -0
  207. package/dist/ui/assets/rosmsg-BJDFO7_C.js +1 -0
  208. package/dist/ui/assets/rs-BhsT8Mbu.js +1 -0
  209. package/dist/ui/assets/rst-C2w80Rnv.js +1 -0
  210. package/dist/ui/assets/ruby-signature-Dr53Dmnh.js +1 -0
  211. package/dist/ui/assets/sas-BGn39htG.js +1 -0
  212. package/dist/ui/assets/sass-Cj5Yp3dK.js +1 -0
  213. package/dist/ui/assets/scad-C1CbN4fu.js +1 -0
  214. package/dist/ui/assets/scala-CqE71os6.js +1 -0
  215. package/dist/ui/assets/scheme-C98Dy4si.js +1 -0
  216. package/dist/ui/assets/scss-Cw6XMfVs.js +1 -0
  217. package/dist/ui/assets/shader-Ca6eN1Do.js +1 -0
  218. package/dist/ui/assets/smalltalk-BOQMe2GC.js +1 -0
  219. package/dist/ui/assets/smithy-cds9vsN8.js +1 -0
  220. package/dist/ui/assets/solidity-DijEV5ha.js +1 -0
  221. package/dist/ui/assets/sparql-rVzFXLq3.js +1 -0
  222. package/dist/ui/assets/spl-CYnMqPaz.js +1 -0
  223. package/dist/ui/assets/sql-DkvoVjww.js +1 -0
  224. package/dist/ui/assets/ssh-config-_ykCGR6B.js +1 -0
  225. package/dist/ui/assets/stata-DACIj5bg.js +1 -0
  226. package/dist/ui/assets/styl-BRLZixNf.js +1 -0
  227. package/dist/ui/assets/surql-DuXNjW2b.js +1 -0
  228. package/dist/ui/assets/svelte-Co4ixzwc.js +1 -0
  229. package/dist/ui/assets/swift-C2oV4EkX.js +1 -0
  230. package/dist/ui/assets/system-verilog-0hqHdDBg.js +1 -0
  231. package/dist/ui/assets/systemd-4A_iFExJ.js +1 -0
  232. package/dist/ui/assets/talon-Byugm8Eo.js +1 -0
  233. package/dist/ui/assets/tasl-QIJgUcNo.js +1 -0
  234. package/dist/ui/assets/tcl-dwOrl1Do.js +1 -0
  235. package/dist/ui/assets/templ-8ecetEyQ.js +1 -0
  236. package/dist/ui/assets/tex-D96PA37w.js +1 -0
  237. package/dist/ui/assets/tfvars-C4cyYA4z.js +1 -0
  238. package/dist/ui/assets/toml-vGWfd6FD.js +1 -0
  239. package/dist/ui/assets/ts-muzv-B4Z.js +1 -0
  240. package/dist/ui/assets/tscn-DU9HihWa.js +1 -0
  241. package/dist/ui/assets/tsp-Ds05qIuC.js +1 -0
  242. package/dist/ui/assets/tsv-B_m7g4N7.js +1 -0
  243. package/dist/ui/assets/tsx-COt5Ahok.js +1 -0
  244. package/dist/ui/assets/turtle-BsS91CYL.js +1 -0
  245. package/dist/ui/assets/twig-C3N1ALdA.js +1 -0
  246. package/dist/ui/assets/typ-BREikwzP.js +1 -0
  247. package/dist/ui/assets/v-BGw2Nkan.js +1 -0
  248. package/dist/ui/assets/vala-CsfeWuGM.js +1 -0
  249. package/dist/ui/assets/vb-Cu-pLBUe.js +1 -0
  250. package/dist/ui/assets/verilog-nZwndyjY.js +1 -0
  251. package/dist/ui/assets/vhdl-CeAyd5Ju.js +1 -0
  252. package/dist/ui/assets/vimscript-Ct4t_lzx.js +1 -0
  253. package/dist/ui/assets/vue-Du5E_mGo.js +1 -0
  254. package/dist/ui/assets/vue-directives-OHNAz0dh.js +1 -0
  255. package/dist/ui/assets/vue-html-D5SNZKGg.js +1 -0
  256. package/dist/ui/assets/vue-interpolations-CiJO6OZ_.js +1 -0
  257. package/dist/ui/assets/vue-sfc-style-variable-injection-Bt3_SK3a.js +1 -0
  258. package/dist/ui/assets/vue-vine-CXe2Z88O.js +1 -0
  259. package/dist/ui/assets/vy-tAjnZflE.js +1 -0
  260. package/dist/ui/assets/wasm-MzD3tlZU.js +1 -0
  261. package/dist/ui/assets/wenyan-BV7otONQ.js +1 -0
  262. package/dist/ui/assets/wgsl-Dx-B1_4e.js +1 -0
  263. package/dist/ui/assets/wiki-CEMRVyHf.js +1 -0
  264. package/dist/ui/assets/wit-5i3qLPDT.js +1 -0
  265. package/dist/ui/assets/wl-CprSW-RW.js +1 -0
  266. package/dist/ui/assets/xml-sdJ4AIDG.js +1 -0
  267. package/dist/ui/assets/xsl-CtQFsRM5.js +1 -0
  268. package/dist/ui/assets/yml-DbVfSbck.js +1 -0
  269. package/dist/ui/assets/zenscript-DVFEvuxE.js +1 -0
  270. package/dist/ui/assets/zig-VOosw3JB.js +1 -0
  271. package/dist/ui/assets/zsh-A78Q77OD.js +1 -0
  272. package/dist/ui/index.html +17 -0
  273. package/package.json +49 -4
@@ -0,0 +1,2836 @@
1
+ #!/usr/bin/env node
2
+
3
+ // packages/orchestrator/src/config.ts
4
+ import { homedir } from "node:os";
5
+
6
+ // packages/orchestrator/src/installation-root.ts
7
+ import { existsSync } from "node:fs";
8
+ import { join, resolve } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ // packages/protocol/src/pi.ts
12
+ var BLOCKING_DIALOG_METHODS = ["select", "confirm", "input", "editor"];
13
+ var BLOCKING = new Set(BLOCKING_DIALOG_METHODS);
14
+ var FIRE_AND_FORGET_UI_METHODS = [
15
+ "notify",
16
+ "setStatus",
17
+ "setWidget",
18
+ "setTitle",
19
+ "set_editor_text"
20
+ ];
21
+ var FIRE_AND_FORGET = new Set(FIRE_AND_FORGET_UI_METHODS);
22
+ function isBlockingDialog(record) {
23
+ if (record.type !== "extension_ui_request") return false;
24
+ return !FIRE_AND_FORGET.has(String(record.method ?? ""));
25
+ }
26
+
27
+ // packages/protocol/src/envelopes.ts
28
+ var PROTOCOL_VERSION = 2;
29
+
30
+ // packages/protocol/src/own-installation.ts
31
+ var PIPELINE_MARKERS = ["packages/orchestrator", "packages/ui", "packages/protocol"];
32
+ function normalizePath(path) {
33
+ const parts = [];
34
+ for (const segment of path.split("/")) {
35
+ if (segment === "" || segment === ".") continue;
36
+ if (segment === "..") {
37
+ parts.pop();
38
+ continue;
39
+ }
40
+ parts.push(segment);
41
+ }
42
+ const joined = parts.join("/");
43
+ return path.startsWith("/") ? `/${joined}` : joined;
44
+ }
45
+ function isOwnInstallation(candidate, installation) {
46
+ const a = normalizePath(candidate);
47
+ const b = normalizePath(installation);
48
+ if (a.length === 0 || b.length === 0) return false;
49
+ if (a === b) return true;
50
+ return isInside(a, b) || isInside(b, a);
51
+ }
52
+ function isInside(child, parent) {
53
+ if (parent === "/") return child !== "/";
54
+ return child.startsWith(`${parent}/`);
55
+ }
56
+ function looksLikePiCentral(paths) {
57
+ const normalized = paths.map(normalizePath);
58
+ return PIPELINE_MARKERS.every(
59
+ (marker) => normalized.some((path) => path === marker || path.endsWith(`/${marker}`))
60
+ );
61
+ }
62
+ var OWN_INSTALLATION_WARNING = "This is the checkout Pi Central is running from. Editing it can hot-reload this UI, desynchronise it from the orchestrator on a branch switch, and leave Pi Central unable to restart if the orchestrator's source is broken \u2014 exactly when you need it. Use a separate clone (see docs/DOGFOODING.md).";
63
+ function ownInstallationWarning(candidate, installation) {
64
+ if (installation === void 0) return void 0;
65
+ return isOwnInstallation(candidate, installation) ? OWN_INSTALLATION_WARNING : void 0;
66
+ }
67
+
68
+ // packages/protocol/src/bytes.ts
69
+ function formatBytes(bytes) {
70
+ if (!Number.isFinite(bytes) || bytes < 0) return "\u2014";
71
+ if (bytes < 1024) return `${bytes} B`;
72
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
73
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
74
+ }
75
+
76
+ // packages/orchestrator/src/installation-root.ts
77
+ function resolveInstallationRoot(moduleUrl = import.meta.url) {
78
+ let current = fileURLToPath(new URL(".", moduleUrl));
79
+ for (let depth = 0; depth < 8; depth += 1) {
80
+ const parent = resolve(current, "..");
81
+ if (parent === current) return void 0;
82
+ if (looksLikeInstallationRoot(parent)) return parent;
83
+ current = parent;
84
+ }
85
+ return void 0;
86
+ }
87
+ function looksLikeInstallationRoot(directory) {
88
+ const markers = [
89
+ join(directory, "packages", "orchestrator"),
90
+ join(directory, "packages", "ui"),
91
+ join(directory, "packages", "protocol")
92
+ ];
93
+ if (!markers.every((marker) => existsSync(marker))) return false;
94
+ return looksLikePiCentral(markers);
95
+ }
96
+
97
+ // packages/orchestrator/src/config.ts
98
+ import { join as join3, resolve as resolve2, sep } from "node:path";
99
+
100
+ // packages/orchestrator/src/agent-store.ts
101
+ import { createHash } from "node:crypto";
102
+ import { existsSync as existsSync2, readFileSync, readdirSync, statSync } from "node:fs";
103
+ import { join as join2 } from "node:path";
104
+ var KEYCHAIN_FILE = "agent.keychain-db";
105
+ var PROVIDER_AUTH_FILE = "auth.json";
106
+ function looksLikeAgentTree(root) {
107
+ if (!existsSync2(root)) return false;
108
+ return existsSync2(join2(root, KEYCHAIN_FILE)) || existsSync2(join2(root, "gitconfig")) || existsSync2(join2(root, "gh")) || existsSync2(join2(root, "ssh"));
109
+ }
110
+ function inspectProviderAuth(agentDir) {
111
+ const path = join2(agentDir, PROVIDER_AUTH_FILE);
112
+ const result = {
113
+ path,
114
+ readable: false,
115
+ providers: [],
116
+ commandProviders: [],
117
+ needsKeychain: false
118
+ };
119
+ let text;
120
+ try {
121
+ text = readFileSync(path, "utf8");
122
+ } catch {
123
+ return result;
124
+ }
125
+ result.readable = true;
126
+ let parsed;
127
+ try {
128
+ parsed = JSON.parse(text);
129
+ } catch {
130
+ return result;
131
+ }
132
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return result;
133
+ for (const [provider, value] of Object.entries(parsed)) {
134
+ result.providers.push(provider);
135
+ if (typeof value !== "object" || value === null) continue;
136
+ const key = value.key;
137
+ if (typeof key !== "string" || !key.startsWith("!")) continue;
138
+ result.commandProviders.push(provider);
139
+ if (/\bsecurity\b/.test(key)) result.needsKeychain = true;
140
+ }
141
+ return result;
142
+ }
143
+ function readStore(root, options = {}) {
144
+ const ownership = options.ownership ?? "managed";
145
+ const exists = existsSync2(root);
146
+ const gitconfigPath = join2(root, "gitconfig");
147
+ const identity = exists && existsSync2(gitconfigPath) ? readIdentity(gitconfigPath) : {};
148
+ const has = {
149
+ gitconfig: existsSync2(gitconfigPath),
150
+ ghConfig: existsSync2(join2(root, "gh")),
151
+ sshKey: existsSync2(join2(root, "ssh", "github_login_ed25519")) || existsSync2(join2(root, "ssh", "id_ed25519")),
152
+ sshSigningKey: existsSync2(join2(root, "ssh", "allowed_signers")),
153
+ knownHosts: existsSync2(join2(root, "ssh", "known_hosts")),
154
+ sshCommandWrapper: existsSync2(join2(root, "ssh", "ssh-github.sh")),
155
+ keychain: existsSync2(join2(root, KEYCHAIN_FILE))
156
+ };
157
+ const files = exists ? listFiles(root) : [];
158
+ const snapshot = { root, exists, ownership, identity, has, files, fingerprint: "" };
159
+ snapshot.fingerprint = fingerprintOf(snapshot);
160
+ return snapshot;
161
+ }
162
+ function fingerprintOf(snapshot) {
163
+ const facts = {
164
+ exists: snapshot.exists,
165
+ identity: snapshot.identity,
166
+ has: snapshot.has,
167
+ files: snapshot.files.map((f) => [f.relPath, f.mode, f.size])
168
+ };
169
+ return createHash("sha256").update(JSON.stringify(facts)).digest("hex").slice(0, 16);
170
+ }
171
+ function listFiles(root) {
172
+ const found = [];
173
+ const walk = (dir, rel, depth) => {
174
+ if (depth > 2) return;
175
+ let entries;
176
+ try {
177
+ entries = readdirSync(dir, { withFileTypes: true });
178
+ } catch {
179
+ return;
180
+ }
181
+ for (const entry of entries) {
182
+ const relPath = rel === "" ? entry.name : `${rel}/${entry.name}`;
183
+ const full = join2(dir, entry.name);
184
+ try {
185
+ const stats = statSync(full);
186
+ if (stats.isDirectory()) {
187
+ walk(full, relPath, depth + 1);
188
+ } else {
189
+ found.push({ relPath, mode: stats.mode & 511, size: stats.size });
190
+ }
191
+ } catch {
192
+ }
193
+ }
194
+ };
195
+ walk(root, "", 0);
196
+ return found.sort((a, b) => a.relPath.localeCompare(b.relPath));
197
+ }
198
+ function readIdentity(gitconfigPath) {
199
+ const identity = {};
200
+ let text;
201
+ try {
202
+ text = readFileSync(gitconfigPath, "utf8");
203
+ } catch {
204
+ return identity;
205
+ }
206
+ let inUser = false;
207
+ for (const raw of text.split("\n")) {
208
+ const line = raw.trim();
209
+ if (line.startsWith("[")) {
210
+ inUser = /^\[user\]/i.test(line);
211
+ continue;
212
+ }
213
+ if (!inUser) continue;
214
+ const match = /^(name|email)\s*=\s*(.+)$/i.exec(line);
215
+ if (!match) continue;
216
+ const value = match[2].trim();
217
+ if (value === "") continue;
218
+ if (match[1].toLowerCase() === "name") identity.name = value;
219
+ else identity.email = value;
220
+ }
221
+ return identity;
222
+ }
223
+
224
+ // packages/orchestrator/src/config.ts
225
+ function resolveAgentRoot(stateRoot, env = process.env, home = homedir()) {
226
+ const explicit = env["PI_CENTRAL_AGENT_ROOT"];
227
+ if (explicit !== void 0 && explicit !== "") return { agentRoot: explicit, managed: false };
228
+ const conventional = join3(home, ".agent");
229
+ if (looksLikeAgentTree(conventional)) return { agentRoot: conventional, managed: false };
230
+ return { agentRoot: join3(stateRoot, "agents", "default"), managed: true };
231
+ }
232
+ function defaultConfig() {
233
+ const home = homedir();
234
+ const stateRoot = process.env.PI_CENTRAL_STATE ?? join3(home, ".config", "pi-central");
235
+ const { agentRoot, managed } = resolveAgentRoot(stateRoot);
236
+ return {
237
+ workspaceRoot: process.env.PI_CENTRAL_WORKSPACE ?? join3(home, ".local", "share", "pi-central", "workspaces"),
238
+ stateRoot,
239
+ sessionsRoot: process.env.PI_SESSIONS_DIR ?? join3(home, ".pi", "agent", "sessions"),
240
+ safehouse: process.env.PI_CENTRAL_SAFEHOUSE ?? "safehouse",
241
+ pi: process.env.PI_CENTRAL_PI ?? "pi",
242
+ agentRoot,
243
+ fragmentDir: join3(stateRoot, "policy"),
244
+ agentRootManaged: managed,
245
+ home,
246
+ // Where a session actually reads provider configuration. Defaults to Pi's own agent dir,
247
+ // because Pi Central does not currently pass `PI_CODING_AGENT_DIR`; if that changes, this
248
+ // must follow, or the policy would grant keychain access for the wrong location.
249
+ providerAgentDir: process.env.PI_CODING_AGENT_DIR ?? join3(home, ".pi", "agent"),
250
+ // Derived from the ORCHESTRATOR's module URL, never from configuration (issue #25): see
251
+ // `installation-root.ts`. `defaultConfig` is the orchestrator's entry point, so resolving here
252
+ // means the answer is the running installation and not whatever a config file claims.
253
+ installationRoot: resolveInstallationRoot()
254
+ };
255
+ }
256
+ function contains(parent, child) {
257
+ const p = resolve2(parent);
258
+ const c = resolve2(child);
259
+ if (c === p) return true;
260
+ return c.startsWith(p.endsWith(sep) ? p : p + sep);
261
+ }
262
+ function scratchDirFor(config, sessionId) {
263
+ if (!/^[A-Za-z0-9._-]+$/.test(sessionId)) {
264
+ throw new Error(`refusing to build a workspace path from an unsafe id: ${JSON.stringify(sessionId)}`);
265
+ }
266
+ return join3(config.workspaceRoot, sessionId);
267
+ }
268
+ function isScratch(config, cwd) {
269
+ return contains(config.workspaceRoot, cwd);
270
+ }
271
+
272
+ // packages/orchestrator/src/supervisor.ts
273
+ import { EventEmitter as EventEmitter2 } from "node:events";
274
+ import { existsSync as existsSync4, mkdirSync as mkdirSync2 } from "node:fs";
275
+ import { join as join8 } from "node:path";
276
+
277
+ // packages/orchestrator/src/policy.ts
278
+ import { accessSync, constants, mkdirSync, statSync as statSync2, writeFileSync } from "node:fs";
279
+ import { join as join4, resolve as resolve3 } from "node:path";
280
+
281
+ // packages/orchestrator/src/env.ts
282
+ var PASSTHROUGH = [
283
+ // Process basics a CLI needs to behave normally.
284
+ "HOME",
285
+ "USER",
286
+ "LOGNAME",
287
+ "SHELL",
288
+ "PATH",
289
+ "TMPDIR",
290
+ "LANG",
291
+ "LC_ALL",
292
+ "TERM",
293
+ "COLORTERM",
294
+ "EDITOR",
295
+ "VISUAL",
296
+ // Pi's own switches (environment-variables.md). These select behaviour, not identity, so
297
+ // passing them preserves user intent without carrying a credential.
298
+ "PI_OFFLINE",
299
+ "PI_SKIP_VERSION_CHECK",
300
+ "PI_TIMING",
301
+ "PI_CODING_AGENT_DIR",
302
+ "PI_CODING_AGENT_SESSION_DIR",
303
+ "PI_PACKAGE_DIR",
304
+ "PI_SUBAGENT_PI_BINARY"
305
+ ];
306
+ var FORBIDDEN_NAMES = /* @__PURE__ */ new Set(["ssh_auth_sock", "ssh_agent_pid"]);
307
+ function buildSessionEnv(options = {}) {
308
+ const source = options.source ?? process.env;
309
+ const env = {};
310
+ for (const name of PASSTHROUGH) {
311
+ const value = source[name];
312
+ if (value !== void 0) env[name] = value;
313
+ }
314
+ env["PATH"] ??= options.fallbackPath ?? DEFAULT_PATH;
315
+ for (const [name, value] of Object.entries(options.set ?? {})) {
316
+ assertInjectableName(name);
317
+ env[name] = value;
318
+ }
319
+ return env;
320
+ }
321
+ var DEFAULT_PATH = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
322
+ function agentRoutingEnv(agentRoot) {
323
+ if (!agentRoot.startsWith("/")) {
324
+ throw new Error(`agent root must be an absolute path: ${JSON.stringify(agentRoot)}`);
325
+ }
326
+ return {
327
+ GH_CONFIG_DIR: `${agentRoot}/gh`,
328
+ GIT_CONFIG_GLOBAL: `${agentRoot}/gitconfig`,
329
+ GIT_CONFIG_SYSTEM: "/dev/null"
330
+ };
331
+ }
332
+ function assertInjectableName(name) {
333
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
334
+ throw new Error(`refusing to inject a malformed env var name: ${JSON.stringify(name)}`);
335
+ }
336
+ if (FORBIDDEN_NAMES.has(name.toLowerCase())) {
337
+ throw new Error(
338
+ `refusing to inject ${name}: it would expose the host user's ssh-agent to the session. An agent that needs SSH uses its own key with IdentitiesOnly=yes (ADR-0010 \xA74).`
339
+ );
340
+ }
341
+ }
342
+
343
+ // packages/orchestrator/src/policy.ts
344
+ var FORBIDDEN_ENABLE = /* @__PURE__ */ new Set([
345
+ "wide-read",
346
+ "all-agents",
347
+ "all-apps",
348
+ "ssh",
349
+ "keychain",
350
+ "cloud-credentials",
351
+ "cloud-storage"
352
+ ]);
353
+ var FORBIDDEN_FLAGS = ["--allow-workdir-config-writes", "--allow-profile-writes"];
354
+ function composePolicy(spec) {
355
+ const warnings = [];
356
+ assertLaunchableDirectory(spec.cwd);
357
+ if (spec.mode === "direct") {
358
+ return {
359
+ launch: {
360
+ command: spec.pi,
361
+ args: ["--mode", "rpc", ...spec.name ? ["--name", spec.name] : []],
362
+ cwd: spec.cwd,
363
+ env: buildSessionEnv(spec.env ? { set: spec.env } : {})
364
+ },
365
+ isolation: "NONE \u2014 pi runs unsandboxed, inheriting this process's sandbox (if any)",
366
+ warnings: ["Direct mode applies no Safehouse policy. Feasibility testing only."]
367
+ };
368
+ }
369
+ const enable = (spec.enable ?? []).filter((feature) => {
370
+ if (FORBIDDEN_ENABLE.has(feature)) {
371
+ warnings.push(`Refused --enable=${feature}: it grants access beyond the workdir.`);
372
+ return false;
373
+ }
374
+ return true;
375
+ });
376
+ const fragmentFile = materializeFragment(spec);
377
+ const args = ["--workdir", spec.cwd];
378
+ if (enable.length > 0) args.push(`--enable=${enable.join(",")}`);
379
+ if (fragmentFile !== void 0) args.push(`--append-profile=${fragmentFile}`);
380
+ const env = buildSessionEnv(spec.env ? { set: spec.env } : {});
381
+ const injected = Object.keys(spec.env ?? {});
382
+ if (injected.length > 0) args.push(`--env-pass=${injected.join(",")}`);
383
+ for (const bad of FORBIDDEN_FLAGS) {
384
+ if (args.includes(bad)) throw new Error(`refusing to pass ${bad}`);
385
+ }
386
+ args.push("--", spec.pi, "--mode", "rpc");
387
+ if (spec.name) args.push("--name", spec.name);
388
+ const result = {
389
+ launch: { command: spec.safehouse, args, cwd: spec.cwd, env },
390
+ isolation: `Safehouse workdir=${spec.cwd}${enable.length ? ` enable=${enable.join(",")}` : ""}${fragmentFile !== void 0 ? " +bespoke-fragment" : ""}`,
391
+ warnings
392
+ };
393
+ if (fragmentFile !== void 0) result.fragmentFile = fragmentFile;
394
+ return result;
395
+ }
396
+ function materializeFragment(spec) {
397
+ if (spec.policyFragment === void 0) {
398
+ if (spec.fragmentDir !== void 0) {
399
+ throw new Error("fragmentDir was given without a policyFragment to write");
400
+ }
401
+ return void 0;
402
+ }
403
+ if (spec.fragmentDir === void 0) {
404
+ throw new Error("a policyFragment needs a fragmentDir to be written into");
405
+ }
406
+ const dir = resolve3(spec.fragmentDir);
407
+ mkdirSync(dir, { recursive: true, mode: 448 });
408
+ const file = join4(dir, `${spec.id}.sb`);
409
+ writeFileSync(file, spec.policyFragment, { mode: 384 });
410
+ return file;
411
+ }
412
+ function assertLaunchableDirectory(dir) {
413
+ let stats;
414
+ try {
415
+ stats = statSync2(dir);
416
+ } catch {
417
+ throw new Error(`working directory does not exist: ${dir}`);
418
+ }
419
+ if (!stats.isDirectory()) throw new Error(`working directory is not a directory: ${dir}`);
420
+ try {
421
+ accessSync(dir, constants.R_OK | constants.X_OK);
422
+ } catch {
423
+ throw new Error(`working directory is not readable: ${dir}`);
424
+ }
425
+ }
426
+ function describe(policy) {
427
+ return [policy.launch.command, ...policy.launch.args].join(" ");
428
+ }
429
+
430
+ // packages/orchestrator/src/rpc.ts
431
+ import { spawn } from "node:child_process";
432
+ import { EventEmitter } from "node:events";
433
+
434
+ // packages/orchestrator/src/framing.ts
435
+ var JsonlDecoder = class {
436
+ #buffer = Buffer.alloc(0);
437
+ /** Feed a chunk; returns any complete records it completed. */
438
+ push(chunk) {
439
+ this.#buffer = this.#buffer.length === 0 ? chunk : Buffer.concat([this.#buffer, chunk]);
440
+ return this.#drain();
441
+ }
442
+ /** Records left when the stream ends. A trailing record without LF is still valid. */
443
+ flush() {
444
+ const out = this.#drain();
445
+ if (this.#buffer.length > 0) {
446
+ const record = this.#parse(this.#buffer);
447
+ this.#buffer = Buffer.alloc(0);
448
+ if (record) out.push(record);
449
+ }
450
+ return out;
451
+ }
452
+ #drain() {
453
+ const records = [];
454
+ for (; ; ) {
455
+ const nl = this.#buffer.indexOf(10);
456
+ if (nl === -1) break;
457
+ const line = this.#buffer.subarray(0, nl);
458
+ this.#buffer = this.#buffer.subarray(nl + 1);
459
+ const record = this.#parse(line);
460
+ if (record) records.push(record);
461
+ }
462
+ return records;
463
+ }
464
+ #parse(raw) {
465
+ let line = raw;
466
+ if (line.length > 0 && line[line.length - 1] === 13) line = line.subarray(0, line.length - 1);
467
+ if (line.length === 0) return null;
468
+ let parsed;
469
+ try {
470
+ parsed = JSON.parse(line.toString("utf8"));
471
+ } catch {
472
+ return null;
473
+ }
474
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
475
+ const record = parsed;
476
+ if (typeof record.type !== "string" || record.type.length === 0) return null;
477
+ return record;
478
+ }
479
+ };
480
+ function encodeLine(value) {
481
+ return `${JSON.stringify(value)}
482
+ `;
483
+ }
484
+
485
+ // packages/orchestrator/src/rpc.ts
486
+ var RpcProcess = class extends EventEmitter {
487
+ launch;
488
+ #child = null;
489
+ #decoder = new JsonlDecoder();
490
+ #pending = /* @__PURE__ */ new Map();
491
+ #seq = 0;
492
+ #stderr = "";
493
+ #stopping = false;
494
+ #exited = false;
495
+ constructor(launch) {
496
+ super();
497
+ this.launch = launch;
498
+ }
499
+ start() {
500
+ if (this.#child) throw new Error("session already started");
501
+ const child = spawn(this.launch.command, this.launch.args, {
502
+ cwd: this.launch.cwd,
503
+ // Replaces the inherited environment outright. There is no merge: an omitted `env`
504
+ // would mean "inherit everything", which is the failure C11 exists to prevent.
505
+ env: this.launch.env,
506
+ stdio: ["pipe", "pipe", "pipe"],
507
+ // Own process group, so a wedged session cannot outlive the dashboard.
508
+ detached: true
509
+ });
510
+ this.#child = child;
511
+ child.stdout.on("data", (chunk) => {
512
+ for (const record of this.#decoder.push(chunk)) this.#dispatch(record);
513
+ });
514
+ child.stderr.on("data", (chunk) => {
515
+ const text = chunk.toString("utf8");
516
+ this.#stderr += text;
517
+ this.emit("stderr", text);
518
+ });
519
+ child.on("error", (error) => this.emit("failed", error));
520
+ child.on("exit", (code, signal) => {
521
+ this.#exited = true;
522
+ for (const record of this.#decoder.flush()) this.#dispatch(record);
523
+ for (const [, pending] of this.#pending) {
524
+ clearTimeout(pending.timer);
525
+ pending.reject(new Error(`pi exited before responding (code=${code ?? "-"}, signal=${signal ?? "-"})`));
526
+ }
527
+ this.#pending.clear();
528
+ this.emit("exit", { code, signal, stderr: this.stderrTail });
529
+ });
530
+ return this;
531
+ }
532
+ #dispatch(record) {
533
+ if (record.type === "response" && typeof record.id === "string") {
534
+ const pending = this.#pending.get(record.id);
535
+ if (pending) {
536
+ this.#pending.delete(record.id);
537
+ clearTimeout(pending.timer);
538
+ if (record.success === false) {
539
+ pending.reject(new Error(String(record.error ?? "command failed")));
540
+ } else {
541
+ pending.resolve(record.data);
542
+ }
543
+ return;
544
+ }
545
+ }
546
+ this.emit("record", record);
547
+ }
548
+ /** Send a command and resolve with its response `data`, or reject on error/timeout. */
549
+ /**
550
+ * Send a command and await its correlated response.
551
+ *
552
+ * Takes the CLOSED `Command` union, not `{ type: string } & Record<string, unknown>`.
553
+ *
554
+ * This is not a style preference. The union was documented as "closed, on purpose: the
555
+ * supervisor must not be able to send an arbitrary object derived from untrusted input", but
556
+ * `send` accepted any object with a `type` string — so the union was decorative and a typo
557
+ * (`get_message` for `get_messages`) compiled cleanly and failed only at runtime, as a
558
+ * `success: false` response the caller might not check. `get_messages` was in fact absent from
559
+ * the union while being called from the supervisor, which is exactly how that goes unnoticed.
560
+ */
561
+ send(command, timeoutMs = 6e4) {
562
+ const child = this.#child;
563
+ if (!child || !child.stdin.writable) return Promise.reject(new Error("session is not running"));
564
+ const id = `c${++this.#seq}`;
565
+ return new Promise((resolve8, reject) => {
566
+ const timer = setTimeout(() => {
567
+ this.#pending.delete(id);
568
+ reject(new Error(`timeout after ${timeoutMs}ms waiting for ${command.type}`));
569
+ }, timeoutMs);
570
+ timer.unref?.();
571
+ this.#pending.set(id, { resolve: resolve8, reject, timer });
572
+ child.stdin.write(encodeLine({ ...command, id }));
573
+ });
574
+ }
575
+ /** Fire-and-forget write, for records that produce no `response` (extension UI). */
576
+ write(record) {
577
+ const child = this.#child;
578
+ if (child?.stdin.writable) child.stdin.write(encodeLine(record));
579
+ }
580
+ /**
581
+ * Bounded, graceful teardown. Close stdin first (Pi's preferred shutdown), then escalate
582
+ * to the process group.
583
+ *
584
+ * Waits at every step, including after SIGKILL, so that when this resolves the process has
585
+ * actually been reaped. Firing the final signal and returning immediately would leave
586
+ * `running` true for a process that is already dying, and a caller doing `stopAll()` before
587
+ * exit could not tell a clean shutdown from a wedged one.
588
+ */
589
+ async stop(graceMs = 1500) {
590
+ if (this.#stopping) return;
591
+ this.#stopping = true;
592
+ const child = this.#child;
593
+ if (!child || this.#exited) return;
594
+ try {
595
+ child.stdin.end();
596
+ } catch {
597
+ }
598
+ const pid = child.pid;
599
+ if (await this.#waitForExit(graceMs)) return;
600
+ try {
601
+ if (pid) process.kill(-pid, "SIGTERM");
602
+ } catch {
603
+ }
604
+ if (await this.#waitForExit(2e3)) return;
605
+ try {
606
+ if (pid) process.kill(-pid, "SIGKILL");
607
+ } catch {
608
+ }
609
+ await this.#waitForExit(2e3);
610
+ }
611
+ #waitForExit(ms) {
612
+ const child = this.#child;
613
+ if (!child || this.#exited) return Promise.resolve(true);
614
+ return new Promise((resolve8) => {
615
+ const timer = setTimeout(() => resolve8(this.#exited), ms);
616
+ timer.unref?.();
617
+ child.once("exit", () => {
618
+ clearTimeout(timer);
619
+ resolve8(true);
620
+ });
621
+ });
622
+ }
623
+ get stderrTail() {
624
+ return this.#stderr.split("\n").slice(-12).join("\n").trim();
625
+ }
626
+ get running() {
627
+ const child = this.#child;
628
+ return child !== null && !this.#exited;
629
+ }
630
+ };
631
+
632
+ // packages/orchestrator/src/resume.ts
633
+ import { accessSync as accessSync2, constants as constants2, statSync as statSync3 } from "node:fs";
634
+ import { resolve as resolve4 } from "node:path";
635
+ function checkResumable(session) {
636
+ if (!session.cwd) {
637
+ return {
638
+ ok: false,
639
+ reason: "unknown-cwd",
640
+ cwd: session.cwd,
641
+ message: "This session does not record the directory it ran in, so a sandbox cannot be composed for it. Start a new session in the directory you want."
642
+ };
643
+ }
644
+ let stats;
645
+ try {
646
+ stats = statSync3(session.cwd);
647
+ } catch (error) {
648
+ const code = error.code;
649
+ const unreadable = code === "EACCES" || code === "EPERM";
650
+ return {
651
+ ok: false,
652
+ reason: unreadable ? "unreadable" : "missing-directory",
653
+ cwd: session.cwd,
654
+ message: unreadable ? `The directory this session worked in cannot be read (${code}):
655
+ ${session.cwd}
656
+
657
+ This is a permissions problem, not a missing directory. Check that you can enter it: it may be owned by another user, or a parent directory may deny access.` : `The directory this session worked in no longer exists:
658
+ ${session.cwd}
659
+
660
+ Restore it, or start a new session in the directory you want to work in. Pi Central does not move a session between directories.`
661
+ };
662
+ }
663
+ if (!stats.isDirectory()) {
664
+ return {
665
+ ok: false,
666
+ reason: "not-a-directory",
667
+ cwd: session.cwd,
668
+ message: `The directory this session recorded is no longer a directory:
669
+ ${session.cwd}`
670
+ };
671
+ }
672
+ try {
673
+ accessSync2(session.cwd, constants2.R_OK | constants2.X_OK);
674
+ } catch {
675
+ return {
676
+ ok: false,
677
+ reason: "unreadable",
678
+ cwd: session.cwd,
679
+ message: `The directory this session worked in is not readable:
680
+ ${session.cwd}`
681
+ };
682
+ }
683
+ return { ok: true, cwd: resolve4(session.cwd) };
684
+ }
685
+
686
+ // packages/orchestrator/src/capabilities.ts
687
+ import { accessSync as accessSync3, constants as constants3 } from "node:fs";
688
+ import { join as join6 } from "node:path";
689
+
690
+ // packages/orchestrator/src/signing.ts
691
+ import { existsSync as existsSync3, readFileSync as readFileSync2 } from "node:fs";
692
+ import { join as join5, resolve as resolve5, sep as sep2 } from "node:path";
693
+ function signingKeyPath(root) {
694
+ return join5(root, "ssh", "github_login_ed25519");
695
+ }
696
+ function allowedSignersPath(root) {
697
+ return join5(root, "ssh", "allowed_signers");
698
+ }
699
+ var KEYS = {
700
+ [normalizeKey("commit.gpgsign")]: "commitGpgsign",
701
+ [normalizeKey("tag.gpgsign")]: "tagGpgsign",
702
+ [normalizeKey("gpg.format")]: "format",
703
+ [normalizeKey("user.signingkey")]: "signingKey",
704
+ [normalizeKey("gpg.ssh.allowedSignersFile")]: "allowedSignersFile"
705
+ };
706
+ function normalizeKey(key) {
707
+ const parts = key.split(".");
708
+ if (parts.length < 2) return key.toLowerCase();
709
+ const last = parts.length - 1;
710
+ return parts.map((part, index) => index === 0 || index === last ? part.toLowerCase() : part).join(".");
711
+ }
712
+ function parseGitConfig(text) {
713
+ const values = /* @__PURE__ */ new Map();
714
+ let prefix = "";
715
+ for (const raw of text.split("\n")) {
716
+ const line = raw.trim();
717
+ if (line === "" || line.startsWith("#") || line.startsWith(";")) continue;
718
+ if (line.startsWith("[")) {
719
+ const match2 = /^\[([^\]"\s]+)(?:\s+"([^"]*)")?\]$/.exec(line);
720
+ prefix = match2?.[2] !== void 0 ? `${match2[1].toLowerCase()}.${match2[2]}` : match2?.[1]?.toLowerCase() ?? "";
721
+ continue;
722
+ }
723
+ const match = /^([A-Za-z][A-Za-z0-9-]*)\s*=\s*(.*)$/.exec(line);
724
+ if (!match || prefix === "") continue;
725
+ values.set(normalizeKey(`${prefix}.${match[1]}`), match[2].trim());
726
+ }
727
+ return values;
728
+ }
729
+ function readSigning(text) {
730
+ const values = parseGitConfig(text);
731
+ const signing = {};
732
+ for (const [key, field] of Object.entries(KEYS)) {
733
+ const value = values.get(key);
734
+ if (value !== void 0 && value !== "") signing[field] = value;
735
+ }
736
+ return signing;
737
+ }
738
+ function isInside2(root, path) {
739
+ const from = resolve5(root);
740
+ const to = resolve5(path);
741
+ if (to === from) return true;
742
+ return to.startsWith(from.endsWith(sep2) ? from : from + sep2);
743
+ }
744
+ function auditSigning(settings, root) {
745
+ const problems = [];
746
+ const keyPath = signingKeyPath(root);
747
+ const signersPath = allowedSignersPath(root);
748
+ let misconfigured = false;
749
+ if (settings.signingKey !== void 0 && !isInside2(root, settings.signingKey)) {
750
+ misconfigured = true;
751
+ problems.push(
752
+ `user.signingkey is ${settings.signingKey}, which is OUTSIDE the agent root ${root}. ssh takes a key path rather than key contents, so this would sign commits as whoever owns that key \u2014 a leak of the user's identity, not a formatting problem.`
753
+ );
754
+ }
755
+ if (settings.allowedSignersFile !== void 0 && !isInside2(root, settings.allowedSignersFile)) {
756
+ misconfigured = true;
757
+ problems.push(
758
+ `gpg.ssh.allowedSignersFile is ${settings.allowedSignersFile}, which is outside the agent root ${root}. Verification would consult the host's trust list instead of the agent's.`
759
+ );
760
+ }
761
+ if (settings.format !== void 0 && settings.format !== "ssh") {
762
+ misconfigured = true;
763
+ problems.push(
764
+ `gpg.format is ${JSON.stringify(settings.format)}, not "ssh". The default is openpgp, which looks for a gpg installation the session does not need and may not have.`
765
+ );
766
+ }
767
+ if (misconfigured) return { state: "misconfigured", problems, needsFix: true };
768
+ const disabled = !truthy(settings.commitGpgsign) || !truthy(settings.tagGpgsign);
769
+ if (disabled) {
770
+ if (!truthy(settings.commitGpgsign)) problems.push("commit.gpgsign is not true");
771
+ if (!truthy(settings.tagGpgsign)) problems.push("tag.gpgsign is not true");
772
+ return { state: "disabled", problems, needsFix: true };
773
+ }
774
+ if (settings.format === void 0) problems.push("gpg.format is unset (must be ssh)");
775
+ if (settings.signingKey === void 0) problems.push(`user.signingkey is unset (must be ${keyPath})`);
776
+ if (settings.allowedSignersFile === void 0) {
777
+ problems.push(`gpg.ssh.allowedSignersFile is unset (must be ${signersPath})`);
778
+ }
779
+ if (problems.length > 0) return { state: "incomplete", problems, needsFix: true };
780
+ return { state: "ok", problems: [], needsFix: false };
781
+ }
782
+ function truthy(value) {
783
+ if (value === void 0) return false;
784
+ return ["true", "1", "yes", "on"].includes(value.toLowerCase());
785
+ }
786
+ function signingMaterial(root) {
787
+ return { key: existsSync3(signingKeyPath(root)), allowedSigners: existsSync3(allowedSignersPath(root)) };
788
+ }
789
+ function signingFixCommands(root) {
790
+ return [
791
+ `git config --file ${join5(root, "gitconfig")} commit.gpgsign true`,
792
+ `git config --file ${join5(root, "gitconfig")} tag.gpgsign true`,
793
+ `git config --file ${join5(root, "gitconfig")} gpg.format ssh`,
794
+ `git config --file ${join5(root, "gitconfig")} user.signingkey ${signingKeyPath(root)}`,
795
+ `git config --file ${join5(root, "gitconfig")} gpg.ssh.allowedSignersFile ${allowedSignersPath(root)}`
796
+ ];
797
+ }
798
+
799
+ // packages/orchestrator/src/capabilities.ts
800
+ import { readFileSync as readFileSync3 } from "node:fs";
801
+ function isReadable(path) {
802
+ try {
803
+ accessSync3(path, constants3.R_OK);
804
+ return true;
805
+ } catch {
806
+ return false;
807
+ }
808
+ }
809
+ function fragmentGrants(fragment, path) {
810
+ return fragment.includes(`(subpath ${JSON.stringify(path)})`);
811
+ }
812
+ function fragmentGrantsKeychain(fragment) {
813
+ return fragment.includes('(global-name "com.apple.SecurityServer")') && // The search list is load-bearing: without it `security` silently falls back to
814
+ // [login, System], does not see the agent keychain, and every lookup fails with no useful
815
+ // error (C13). A grant without it looks correct and is not.
816
+ //
817
+ // Matched as the RULE, not as a bare substring, and that distinction was found by a test: the
818
+ // generated fragment also *describes* the file in a comment ("The search list lives in
819
+ // com.apple.security.plist"), so a substring check would pass on a fragment whose only mention
820
+ // of the search list is prose. Matching the `(home-literal …)` form is what makes it a check.
821
+ fragment.includes('(home-literal "/Library/Preferences/com.apple.security.plist")');
822
+ }
823
+ function assessAgentCapabilities(input) {
824
+ const { store, fragment, sandboxed, providerAuth, keychainPath } = input;
825
+ const findings = [];
826
+ const root = store.root;
827
+ const granted = (path) => (
828
+ // In direct mode no policy applies, so the only question is readability — which the checks
829
+ // below answer separately. Returning `true` here keeps "unreachable" meaning "the policy
830
+ // denies it", rather than firing on every direct-mode session.
831
+ !sandboxed || fragment !== void 0 && fragmentGrants(fragment, path)
832
+ );
833
+ const gitconfigPath = join6(root, "gitconfig");
834
+ if (!store.has.gitconfig) {
835
+ findings.push({
836
+ id: "gitconfig",
837
+ state: "not-provisioned",
838
+ message: `the agent has no gitconfig at ${gitconfigPath}, which GIT_CONFIG_GLOBAL points at. Commits would have no identity and git would read no agent settings. Provision it (git config --file ${gitconfigPath} user.name \u2026).`
839
+ });
840
+ } else if (!isReadable(gitconfigPath)) {
841
+ findings.push({
842
+ id: "gitconfig",
843
+ state: "unreachable",
844
+ message: `the agent gitconfig exists at ${gitconfigPath} but is not readable by its owner, so no process at this UID can use it. This is a permissions problem, not a missing setup step.`
845
+ });
846
+ } else if (!granted(root)) {
847
+ findings.push({
848
+ id: "gitconfig",
849
+ state: "unreachable",
850
+ message: `the session's policy does not grant ${root}, while GIT_CONFIG_GLOBAL points into it. The agent would be configured to read a file its own sandbox denies \u2014 a Pi Central bug, not a setup step.`
851
+ });
852
+ }
853
+ const ghDir = join6(root, "gh");
854
+ if (!store.has.ghConfig) {
855
+ findings.push({
856
+ id: "gh-config",
857
+ state: "not-provisioned",
858
+ message: `the agent has no gh config directory at ${ghDir}, which GH_CONFIG_DIR points at. gh would fall back to the host's config, find it denied, and refuse to start. Provision it (gh auth login with GH_CONFIG_DIR set, or copy an existing gh config).`
859
+ });
860
+ } else if (!granted(root)) {
861
+ findings.push({
862
+ id: "gh-config",
863
+ state: "unreachable",
864
+ message: `the session's policy does not grant ${root}, while GH_CONFIG_DIR points into it. gh would read the host's config instead, find that denied, and fail \u2014 a Pi Central bug.`
865
+ });
866
+ } else if (!isReadable(ghDir)) {
867
+ findings.push({
868
+ id: "gh-config",
869
+ state: "unreachable",
870
+ message: `the agent gh config directory exists at ${ghDir} but is not readable by its owner, so gh cannot read it. This is a permissions problem, not a missing setup step.`
871
+ });
872
+ }
873
+ if (!store.has.sshKey) {
874
+ findings.push({
875
+ id: "ssh-key",
876
+ state: "not-provisioned",
877
+ message: `the agent has no SSH key under ${join6(root, "ssh")}. It can still push over HTTPS with its gh token, but not over SSH, and it cannot sign commits. Provision a key to use either.`
878
+ });
879
+ }
880
+ if (store.has.gitconfig) {
881
+ const gitconfigPath2 = join6(root, "gitconfig");
882
+ const settings = readSigning(readGitconfigText(gitconfigPath2));
883
+ const audit = auditSigning(settings, root);
884
+ const material = signingMaterial(root);
885
+ if (audit.needsFix) {
886
+ const commands = signingFixCommands(root).map((command) => ` ${command}`).join("\n");
887
+ findings.push({
888
+ id: "signing",
889
+ state: audit.state === "misconfigured" ? "unreachable" : "not-provisioned",
890
+ message: `the agent's gitconfig does not sign commits or tags: ${audit.problems.join("; ")}. Commits would be created unsigned, SILENTLY \u2014 the key material is present, so nothing else would report a problem (ADR-0010 \xA74). This is an explicit fix, because the agent root is yours to maintain; Pi Central does not rewrite it:
891
+ ${commands}`
892
+ });
893
+ } else if (!material.key || !material.allowedSigners) {
894
+ findings.push({
895
+ id: "signing",
896
+ state: "not-provisioned",
897
+ message: `the agent's gitconfig is configured to sign (gpg.format=ssh, commit.gpgsign=true) but ${!material.key ? `the signing key ${signingKeyPath(root)} is missing` : ""}${!material.key && !material.allowedSigners ? " and " : ""}${!material.allowedSigners ? `the allowed-signers file ${allowedSignersPath(root)} is missing` : ""}. Every commit would fail, rather than be created unsigned.`
898
+ });
899
+ }
900
+ }
901
+ if (providerAuth.needsKeychain && sandboxed) {
902
+ if (fragment === void 0) {
903
+ findings.push({
904
+ id: "keychain",
905
+ state: "unreachable",
906
+ message: `the provider config at ${providerAuth.path} resolves keys via a command (${providerAuth.commandProviders.join(", ")}), but this sandboxed session has no policy fragment at all. The keychain lookup would fail. This is a Pi Central bug.`
907
+ });
908
+ } else if (!fragmentGrantsKeychain(fragment)) {
909
+ findings.push({
910
+ id: "keychain",
911
+ state: "unreachable",
912
+ message: `the provider config at ${providerAuth.path} resolves keys via a command (${providerAuth.commandProviders.join(", ")}), but the session's policy grants no keychain access. The session would start, report idle, and then be unable to reach a model (C13) \u2014 a Pi Central bug, not a setup step.`
913
+ });
914
+ } else if (store.has.keychain && !isReadable(keychainPath)) {
915
+ findings.push({
916
+ id: "keychain",
917
+ state: "unreachable",
918
+ message: `the agent keychain exists at ${keychainPath} but is not readable by its owner. This is a permissions problem, not a missing setup step.`
919
+ });
920
+ }
921
+ }
922
+ const warnings = findings.filter((finding) => finding.state !== "ok").map((finding) => `${label(finding)}: ${finding.message}`);
923
+ return {
924
+ findings,
925
+ warnings,
926
+ usable: findings.every((finding) => finding.state === "ok")
927
+ };
928
+ }
929
+ function readGitconfigText(path) {
930
+ try {
931
+ return readFileSync3(path, "utf8");
932
+ } catch {
933
+ return "";
934
+ }
935
+ }
936
+ function label(finding) {
937
+ const name = {
938
+ gitconfig: "agent git identity",
939
+ "gh-config": "agent GitHub credentials",
940
+ "ssh-key": "agent SSH key",
941
+ keychain: "provider keychain access",
942
+ signing: "agent commit signing"
943
+ }[finding.id];
944
+ const kind = finding.state === "not-provisioned" ? "not provisioned" : "provisioned but unreachable";
945
+ return `${name} ${kind}`;
946
+ }
947
+
948
+ // packages/orchestrator/src/fragment.ts
949
+ function ancestorLiterals(path) {
950
+ if (!path.startsWith("/")) throw new Error(`path must be absolute: ${JSON.stringify(path)}`);
951
+ const parts = path.split("/").filter((part) => part !== "");
952
+ const out = [' (literal "/")'];
953
+ let current = "";
954
+ for (const part of parts) {
955
+ current += `/${part}`;
956
+ out.push(` (literal ${JSON.stringify(current)})`);
957
+ }
958
+ return out;
959
+ }
960
+ var HOST_DENIES = [
961
+ {
962
+ comment: [
963
+ "Host git configuration (closes the credential-helper route)",
964
+ "The host's global gitconfig may set credential.helper = !gh auth git-credential,",
965
+ "which resolves a token from the login keychain - a path no file deny on ~/.ssh",
966
+ "would stop. The agent uses its own GIT_CONFIG_GLOBAL instead (ADR-0010 \xA73)."
967
+ ],
968
+ rules: [
969
+ "(deny file-read*",
970
+ ' (home-prefix "/.gitconfig")',
971
+ ' (home-subpath "/.config/git")',
972
+ ' (home-literal "/.gitattributes")',
973
+ ")"
974
+ ]
975
+ },
976
+ {
977
+ comment: [
978
+ "Host GitHub CLI config",
979
+ "On macOS gh keeps its token in the keychain, but ~/.config/gh may still hold config",
980
+ "for the user's own account, and the write grant in particular was a config-tampering",
981
+ "surface. The agent uses its own GH_CONFIG_DIR. ~/.config stays traversable so tool",
982
+ "discovery (mise, gh, git) still works."
983
+ ],
984
+ rules: ["(deny file-read* file-write*", ' (home-subpath "/.config/gh")', ")", "(allow file-read-metadata", ' (home-literal "/.config")', ")"]
985
+ },
986
+ {
987
+ comment: [
988
+ "The user's SSH world, closed wholesale",
989
+ "",
990
+ "This was three NAME-based regexes (id_*, *_rsa, *_ed25519) plus a deny on",
991
+ "~/.ssh/agent, and the list failed twice over - it was REDUNDANT where it looked",
992
+ "essential and DANGEROUS where it looked harmless:",
993
+ "",
994
+ " - Redundant: the base policy grants only ~/.ssh/config and ~/.ssh/known_hosts, as",
995
+ " literals. Every other path under ~/.ssh was already denied by `(deny default)`.",
996
+ " - Dangerous: the base workdir grant is",
997
+ " `(allow file-read* file-write* (subpath HOME))` and is emitted BEFORE this fragment.",
998
+ " For a session whose cwd is $HOME that grant covers all of ~/.ssh, so the",
999
+ " enumeration became the only thing there - and it did not name cupserver, jenkins,",
1000
+ " or sven1103-agent-strongbox, which are real private keys, nor ~/.ssh/config",
1001
+ " itself, which maps 58 hosts including internal infrastructure.",
1002
+ "",
1003
+ "A list of key NAMES is not a boundary: it fails open on the next key anyone creates",
1004
+ "under a name nobody predicted. The directory is the unit, so the directory is denied.",
1005
+ "",
1006
+ "Why this is safe, measured rather than assumed: the agent does not read this",
1007
+ "directory at all. Its git-over-SSH runs `ssh -F <agentRoot>/ssh/config` through",
1008
+ "core.sshCommand, and `-F` REPLACES the per-user config rather than adding to it.",
1009
+ "With HOME pointed at an empty directory - no ~/.ssh whatsoever - the agent still",
1010
+ "authenticated to GitHub and produced a signature git verified as",
1011
+ '`Good "git" signature for sven1103-agent`.',
1012
+ "",
1013
+ "The second block is deliberate duplication, not tidiness. SBPL resolves a conflict",
1014
+ "by last-matching rule, and Safehouse's own profiles note that a more specific filter",
1015
+ "can beat a broader one regardless of order (`10-system-runtime.sb`). The base emits",
1016
+ '`(home-literal "/.ssh/config")` - a literal, strictly more specific than a subpath -',
1017
+ "so the base's own paths are named again at their own specificity, and a deny wins",
1018
+ "under either reading."
1019
+ ],
1020
+ rules: [
1021
+ "(deny file-read* file-write* file-read-metadata",
1022
+ ' (home-subpath "/.ssh")',
1023
+ ")",
1024
+ "(deny file-read* file-write* file-read-metadata",
1025
+ ' (home-literal "/.ssh")',
1026
+ ' (home-literal "/.ssh/config")',
1027
+ ' (home-literal "/.ssh/known_hosts")',
1028
+ ' (home-literal "/.ssh/known_hosts.old")',
1029
+ ' (home-literal "/.ssh/allowed_signers")',
1030
+ ' (home-subpath "/.ssh/config.d")',
1031
+ ") ;; the base policy's own narrower grants, restated at matching specificity",
1032
+ "",
1033
+ ";; The ssh-agent needs a NETWORK deny as well, since a file deny does not cover",
1034
+ ";; connect() on a unix socket. The file half is subsumed by the wholesale deny above.",
1035
+ ";; A session with agent access can sign as the user with any loaded key WITHOUT",
1036
+ ";; reading a key file; the agent uses its own key with IdentitiesOnly=yes and needs",
1037
+ ";; no agent (ADR-0010 \xA74). Defence in depth: the spawn environment is sanitized too",
1038
+ ";; (C11). Both layers are applied, since either alone is a single point of failure.",
1039
+ "(deny network-outbound",
1040
+ ' (remote unix-socket (path-regex #"^/private/tmp/com\\.apple\\.launchd\\.[^/]+/Listeners$"))',
1041
+ ' (remote unix-socket (path-regex #"^/tmp/com\\.apple\\.launchd\\.[^/]+/Listeners$"))',
1042
+ ' (remote unix-socket (path-regex #"^/private/var/run/com\\.apple\\.launchd\\.[^/]+/Listeners$"))',
1043
+ ' (remote unix-socket (path-regex #"^/var/run/com\\.apple\\.launchd\\.[^/]+/Listeners$"))',
1044
+ ' (remote unix-socket (path-regex (string-append "^" HOME_DIR "/\\\\.ssh/agent(/.*)?$")))',
1045
+ ")"
1046
+ ]
1047
+ }
1048
+ ];
1049
+ function renderPolicyFragment(options = {}) {
1050
+ const { agentRoot, keychain = false, keychainPath } = options;
1051
+ if (agentRoot !== void 0 && !agentRoot.startsWith("/")) {
1052
+ throw new Error(`agent root must be an absolute path: ${JSON.stringify(agentRoot)}`);
1053
+ }
1054
+ if (keychain && (keychainPath === void 0 || !keychainPath.startsWith("/"))) {
1055
+ throw new Error("keychain access requires an absolute keychainPath");
1056
+ }
1057
+ const out = [
1058
+ ";; ---------------------------------------------------------------------------",
1059
+ ";; Pi Central \u2014 appended policy fragment (ADR-0010).",
1060
+ ";;",
1061
+ ";; Generated; do not edit by hand. Deterministic, so drift is diffable.",
1062
+ ";; Appended AFTER Safehouse's generated rules: SBPL is last-match-wins, so these",
1063
+ ";; denies override the base policy's broader allows.",
1064
+ ";;",
1065
+ ";; Purpose: the session runs as the AGENT, not as the user.",
1066
+ ";;",
1067
+ ";; Identity credentials are closed. Provider credentials are NOT denied, and that is",
1068
+ ";; deliberate: the agent runs on the user's model access, so there is no agent-owned",
1069
+ ";; substitute. Denying it would stop the agent thinking, not tighten isolation.",
1070
+ ";; See ADR-0010 Amendment 1.",
1071
+ ";; ---------------------------------------------------------------------------",
1072
+ ""
1073
+ ];
1074
+ for (const block of HOST_DENIES) {
1075
+ out.push(...block.comment.map((line) => line === "" ? ";;" : `;; ${line}`));
1076
+ out.push(...block.rules, "");
1077
+ }
1078
+ if (agentRoot !== void 0) {
1079
+ out.push(
1080
+ ";; --- The agent's OWN credentials -----------------------------------------------",
1081
+ ";; Read-write: gh writes cache/state under GH_CONFIG_DIR, and git may update the",
1082
+ ";; agent's config. This is the point of the fragment - the denies above only help if",
1083
+ ";; the agent can still reach what it legitimately owns.",
1084
+ ";;",
1085
+ ";; Ancestor literals are required, not decorative: SBPL needs read access to every path",
1086
+ ";; component, and Safehouse only emits ancestor grants for the workdir. Without them the",
1087
+ ";; agent cannot traverse into its own tree.",
1088
+ "(allow file-read*",
1089
+ ...ancestorLiterals(agentRoot),
1090
+ ")",
1091
+ "(allow file-read* file-write*",
1092
+ ` (subpath ${JSON.stringify(agentRoot)})`,
1093
+ ")",
1094
+ ""
1095
+ );
1096
+ }
1097
+ if (keychain) {
1098
+ out.push(
1099
+ ";; --- Provider key resolution (C13) ----------------------------------------------",
1100
+ ";; auth.json resolves each provider key with a service-only lookup, e.g.",
1101
+ ";; !security find-generic-password -ws 'litellm'",
1102
+ ";; That needs SecurityServer to read ANY keychain, and the user-wide search list to",
1103
+ ";; find the agent keychain. Neither is granted by Safehouse by default.",
1104
+ ";;",
1105
+ ";; The grant is bounded by denying the login and System keychain FILES: the keychain is",
1106
+ ";; all-or-nothing via IPC, so least privilege (the agent's own keychain) is the",
1107
+ ";; control, not concealment.",
1108
+ "(allow mach-lookup",
1109
+ ' (global-name "com.apple.SecurityServer")',
1110
+ ")",
1111
+ "(deny file-read* file-write*",
1112
+ ' (home-subpath "/Library/Keychains")',
1113
+ ' (subpath "/Library/Keychains")',
1114
+ ")",
1115
+ "(allow file-read* file-write*",
1116
+ ` (literal ${JSON.stringify(keychainPath)})`,
1117
+ ")",
1118
+ ";; The search list lives in com.apple.security.plist. Without read access, `security`",
1119
+ ";; silently falls back to the built-in [login, System] list, does NOT see the agent",
1120
+ ";; keychain, and every service-only lookup fails with no useful error. The file holds",
1121
+ ";; only the search list - no secret.",
1122
+ "(allow file-read*",
1123
+ ' (home-literal "/Library/Preferences/com.apple.security.plist")',
1124
+ ")",
1125
+ ""
1126
+ );
1127
+ }
1128
+ return out.join("\n");
1129
+ }
1130
+
1131
+ // packages/orchestrator/src/files.ts
1132
+ import { lstatSync, readdirSync as readdirSync2, readFileSync as readFileSync4, realpathSync, statSync as statSync4 } from "node:fs";
1133
+ import { isAbsolute, join as join7, resolve as resolve6, sep as sep3 } from "node:path";
1134
+ var MAX_FILE_BYTES = 256 * 1024;
1135
+ var BINARY_SNIFF_BYTES = 8 * 1024;
1136
+ var DEFAULT_ENTRY_LIMIT = 2e3;
1137
+ function containsPath(parent, child) {
1138
+ if (parent === child) return true;
1139
+ const withSep = parent.endsWith(sep3) ? parent : parent + sep3;
1140
+ return child.startsWith(withSep);
1141
+ }
1142
+ function fromErrno(error, what, relPath) {
1143
+ const code = error?.code;
1144
+ const label2 = relPath === "" ? "the working directory" : `"${relPath}"`;
1145
+ switch (code) {
1146
+ case "ENOENT":
1147
+ return { reason: "not-found", code, message: `${what} ${label2}: no such file or directory (ENOENT)` };
1148
+ case "EACCES":
1149
+ case "EPERM":
1150
+ return { reason: "permission", code, message: `${what} ${label2}: permission denied (${code})` };
1151
+ case "ENOTDIR":
1152
+ return { reason: "not-a-directory", code, message: `${what} ${label2}: not a directory (ENOTDIR)` };
1153
+ case "EISDIR":
1154
+ return { reason: "is-directory", code, message: `${what} ${label2}: is a directory (EISDIR)` };
1155
+ default:
1156
+ return {
1157
+ reason: "unreadable",
1158
+ code,
1159
+ message: code === void 0 ? `${what} ${label2}: ${String(error)}` : `${what} ${label2}: ${String(error)} (${code})`
1160
+ };
1161
+ }
1162
+ }
1163
+ function resolveWithin(root, relative) {
1164
+ if (typeof relative !== "string" || relative.includes("\0")) {
1165
+ return {
1166
+ ok: false,
1167
+ error: {
1168
+ reason: "invalid-path",
1169
+ message: "the path contains a NUL byte, which no filesystem path can"
1170
+ }
1171
+ };
1172
+ }
1173
+ if (isAbsolute(relative)) {
1174
+ return {
1175
+ ok: false,
1176
+ error: {
1177
+ reason: "absolute-path",
1178
+ message: `"${relative}" is an absolute path; paths are relative to the session's working directory`
1179
+ }
1180
+ };
1181
+ }
1182
+ const relPath = normalizeRelative(relative);
1183
+ let realRoot;
1184
+ try {
1185
+ realRoot = realpathSync(root);
1186
+ } catch (error) {
1187
+ return { ok: false, error: fromErrno(error, "the session's working directory is unavailable:", "") };
1188
+ }
1189
+ const candidate = resolve6(realRoot, relPath);
1190
+ if (!containsPath(realRoot, candidate)) {
1191
+ return {
1192
+ ok: false,
1193
+ error: {
1194
+ reason: "escapes-cwd",
1195
+ message: `"${relative}" resolves outside the session's working directory`
1196
+ }
1197
+ };
1198
+ }
1199
+ let real;
1200
+ try {
1201
+ real = realpathSync(candidate);
1202
+ } catch (error) {
1203
+ return { ok: false, error: fromErrno(error, "cannot open", relPath) };
1204
+ }
1205
+ if (!containsPath(realRoot, real)) {
1206
+ return {
1207
+ ok: false,
1208
+ error: {
1209
+ reason: "escapes-cwd",
1210
+ message: `"${relative}" is a symlink whose target lies outside the session's working directory, so it is refused rather than followed`
1211
+ }
1212
+ };
1213
+ }
1214
+ return { ok: true, value: { path: real, relPath } };
1215
+ }
1216
+ function normalizeRelative(relative) {
1217
+ const parts = [];
1218
+ for (const part of relative.split(/[/\\]+/)) {
1219
+ if (part === "" || part === ".") continue;
1220
+ parts.push(part);
1221
+ }
1222
+ return parts.join("/");
1223
+ }
1224
+ function isHidden(name) {
1225
+ return name.startsWith(".");
1226
+ }
1227
+ function listDirectory(root, relative, options = {}) {
1228
+ const resolvedPath = resolveWithin(root, relative);
1229
+ if (!resolvedPath.ok) return resolvedPath;
1230
+ const { path, relPath } = resolvedPath.value;
1231
+ const limit = options.limit ?? DEFAULT_ENTRY_LIMIT;
1232
+ const includeHidden = options.includeHidden ?? false;
1233
+ let names;
1234
+ try {
1235
+ names = readdirSync2(path);
1236
+ } catch (error) {
1237
+ return { ok: false, error: fromErrno(error, "cannot list", relPath) };
1238
+ }
1239
+ const entries = [];
1240
+ let truncated = false;
1241
+ for (const name of names) {
1242
+ if (!includeHidden && isHidden(name)) continue;
1243
+ if (entries.length >= limit) {
1244
+ truncated = true;
1245
+ break;
1246
+ }
1247
+ entries.push(describeEntry(join7(path, name), name));
1248
+ }
1249
+ entries.sort((a, b) => {
1250
+ const aDir = a.kind === "directory" ? 0 : 1;
1251
+ const bDir = b.kind === "directory" ? 0 : 1;
1252
+ if (aDir !== bDir) return aDir - bDir;
1253
+ return a.name.localeCompare(b.name, void 0, { sensitivity: "base" });
1254
+ });
1255
+ return { ok: true, value: { path: relPath, entries, truncated } };
1256
+ }
1257
+ function describeEntry(fullPath, name) {
1258
+ try {
1259
+ const stats = lstatSync(fullPath);
1260
+ if (stats.isSymbolicLink()) {
1261
+ return { name, kind: "symlink", linkTarget: linkTargetOf(fullPath), hidden: isHidden(name) };
1262
+ }
1263
+ if (stats.isDirectory()) return { name, kind: "directory", hidden: isHidden(name) };
1264
+ if (stats.isFile()) return { name, kind: "file", size: stats.size, hidden: isHidden(name) };
1265
+ return { name, kind: "other", hidden: isHidden(name) };
1266
+ } catch {
1267
+ return { name, kind: "other", hidden: isHidden(name), unreadable: true };
1268
+ }
1269
+ }
1270
+ function linkTargetOf(fullPath) {
1271
+ try {
1272
+ const stats = statSync4(fullPath);
1273
+ if (stats.isDirectory()) return "directory";
1274
+ if (stats.isFile()) return "file";
1275
+ return "other";
1276
+ } catch {
1277
+ return "missing";
1278
+ }
1279
+ }
1280
+ function readFileText(root, relative) {
1281
+ const resolvedPath = resolveWithin(root, relative);
1282
+ if (!resolvedPath.ok) return resolvedPath;
1283
+ const { path, relPath } = resolvedPath.value;
1284
+ let stats;
1285
+ try {
1286
+ stats = lstatSync(path);
1287
+ } catch (error) {
1288
+ return { ok: false, error: fromErrno(error, "cannot open", relPath) };
1289
+ }
1290
+ if (stats.isDirectory()) {
1291
+ return { ok: false, error: { reason: "is-directory", message: `"${relPath}" is a directory` } };
1292
+ }
1293
+ if (stats.size > MAX_FILE_BYTES) {
1294
+ return {
1295
+ ok: false,
1296
+ error: {
1297
+ reason: "too-large",
1298
+ message: `"${relPath}" is ${formatBytes(stats.size)}, above the ${formatBytes(MAX_FILE_BYTES)} limit for reading a file here, so it was not read`
1299
+ }
1300
+ };
1301
+ }
1302
+ let buffer;
1303
+ try {
1304
+ buffer = readFileSync4(path);
1305
+ } catch (error) {
1306
+ return { ok: false, error: fromErrno(error, "cannot read", relPath) };
1307
+ }
1308
+ const sniff = buffer.subarray(0, BINARY_SNIFF_BYTES);
1309
+ if (sniff.includes(0)) {
1310
+ return {
1311
+ ok: false,
1312
+ error: {
1313
+ reason: "binary",
1314
+ message: `"${relPath}" is a binary file (it contains NUL bytes), so it was not rendered as text`
1315
+ }
1316
+ };
1317
+ }
1318
+ let text;
1319
+ try {
1320
+ text = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
1321
+ } catch {
1322
+ return {
1323
+ ok: false,
1324
+ error: {
1325
+ reason: "binary",
1326
+ message: `"${relPath}" is not valid UTF-8, so it was not rendered as text`
1327
+ }
1328
+ };
1329
+ }
1330
+ return { ok: true, value: { path: relPath, text, bytes: buffer.byteLength } };
1331
+ }
1332
+
1333
+ // packages/orchestrator/src/state.ts
1334
+ function countAsMessage(record) {
1335
+ const message = record.message;
1336
+ return message?.role === "user" || message?.role === "assistant";
1337
+ }
1338
+ function interpretRecord(session, record) {
1339
+ switch (record.type) {
1340
+ case "agent_start":
1341
+ return { patch: { state: "streaming" } };
1342
+ case "agent_end":
1343
+ return {};
1344
+ case "compaction_end":
1345
+ return { refreshStats: true };
1346
+ case "agent_settled":
1347
+ return {
1348
+ patch: { state: session.dialogs.length > 0 ? "awaiting-approval" : "idle" },
1349
+ refreshStats: true
1350
+ };
1351
+ case "message_end":
1352
+ return countAsMessage(record) ? { patch: { messageCount: session.messageCount + 1 } } : {};
1353
+ case "queue_update": {
1354
+ const steering = record.steering?.length ?? 0;
1355
+ const followUp = record.followUp?.length ?? 0;
1356
+ return { patch: { pending: steering + followUp } };
1357
+ }
1358
+ case "thinking_level_changed":
1359
+ return { patch: { thinkingLevel: record.level } };
1360
+ case "extension_ui_request": {
1361
+ if (!isBlockingDialog(record)) return {};
1362
+ const dialog = {
1363
+ id: String(record.id),
1364
+ method: String(record.method),
1365
+ title: record.title,
1366
+ message: record.message,
1367
+ options: record.options,
1368
+ placeholder: record.placeholder,
1369
+ prefill: record.prefill,
1370
+ receivedAt: Date.now()
1371
+ };
1372
+ return { dialog, patch: { state: "awaiting-approval" } };
1373
+ }
1374
+ default:
1375
+ return {};
1376
+ }
1377
+ }
1378
+ var blockingDialogMethods = [...BLOCKING_DIALOG_METHODS];
1379
+ var fireAndForgetUiMethods = [...FIRE_AND_FORGET_UI_METHODS];
1380
+
1381
+ // packages/orchestrator/src/supervisor.ts
1382
+ var Supervisor = class extends EventEmitter2 {
1383
+ #config;
1384
+ #sessions = /* @__PURE__ */ new Map();
1385
+ #processes = /* @__PURE__ */ new Map();
1386
+ #created = 0;
1387
+ constructor(config) {
1388
+ super();
1389
+ this.#config = config;
1390
+ this.#installationRoot = resolveInstallationRoot();
1391
+ }
1392
+ list() {
1393
+ return [...this.#sessions.values()];
1394
+ }
1395
+ get(id) {
1396
+ return this.#sessions.get(id);
1397
+ }
1398
+ /**
1399
+ * The underlying RPC process, for verification and diagnostics.
1400
+ *
1401
+ * Exposed so a test can issue a probe through the *model-facing* `bash` tool inside a
1402
+ * session (verify-three.ts). Normal product code goes through the typed lifecycle methods
1403
+ * above; this is the escape hatch that keeps the harness honest about what it exercises.
1404
+ */
1405
+ rpcProcess(id) {
1406
+ return this.#processes.get(id);
1407
+ }
1408
+ /**
1409
+ * Start a new session. When `cwd` is omitted this creates and uses the app-owned default
1410
+ * workspace for the new session's id (US-1 with US-3), rather than defaulting to `$HOME`
1411
+ * or the orchestrator's own directory.
1412
+ */
1413
+ start(request = {}) {
1414
+ const mode = request.mode ?? "sandboxed";
1415
+ const id = `s${++this.#created}`;
1416
+ let cwd;
1417
+ if (request.cwd) {
1418
+ cwd = request.cwd;
1419
+ } else {
1420
+ try {
1421
+ cwd = scratchDirFor(this.#config, id);
1422
+ mkdirSync2(cwd, { recursive: true });
1423
+ } catch (error) {
1424
+ return { ok: false, message: error.message };
1425
+ }
1426
+ }
1427
+ return this.#spawn({ id, cwd, mode, name: request.name, enable: request.enable, env: request.env });
1428
+ }
1429
+ /**
1430
+ * Resume a historical session in its own recorded directory (US-5).
1431
+ *
1432
+ * The cwd is validated here, before spawning, because RPC-mode Pi exits `1` on a missing
1433
+ * directory rather than prompting (C2). The session's directory is never changed
1434
+ * (ADR-0008); an unusable directory is refused, not retargeted.
1435
+ */
1436
+ resume(session, options = {}) {
1437
+ const readiness = checkResumable(session);
1438
+ if (!readiness.ok) return { ok: false, message: readiness.message };
1439
+ const live = this.#findLiveByPath(session.path);
1440
+ if (live) return { ok: true, session: live };
1441
+ const id = `s${++this.#created}`;
1442
+ return this.#spawn({
1443
+ id,
1444
+ cwd: readiness.cwd,
1445
+ mode: options.mode ?? "sandboxed",
1446
+ // No name: the session already carries its own, and re-applying one would append a
1447
+ // redundant session_info entry to a file we did not create.
1448
+ ...options.env ? { env: options.env } : {},
1449
+ sessionPath: session.path
1450
+ });
1451
+ }
1452
+ #findLiveByPath(sessionPath) {
1453
+ for (const session of this.#sessions.values()) {
1454
+ if (session.sessionFile === sessionPath && session.state !== "stopped" && session.state !== "failed") {
1455
+ return session;
1456
+ }
1457
+ }
1458
+ return void 0;
1459
+ }
1460
+ #spawn(input) {
1461
+ const env = { ...agentRoutingEnv(this.#config.agentRoot), ...input.env ?? {} };
1462
+ const providerAuth = inspectProviderAuth(this.#config.providerAgentDir);
1463
+ const keychainPath = join8(this.#config.agentRoot, KEYCHAIN_FILE);
1464
+ const keychainNeeded = providerAuth.needsKeychain;
1465
+ const keychainPresent = existsSync4(keychainPath);
1466
+ const fragment = input.mode === "sandboxed" ? renderPolicyFragment({
1467
+ agentRoot: this.#config.agentRoot,
1468
+ ...keychainNeeded ? { keychain: true, keychainPath } : {}
1469
+ }) : void 0;
1470
+ const spec = {
1471
+ id: input.id,
1472
+ cwd: input.cwd,
1473
+ mode: input.mode,
1474
+ safehouse: this.#config.safehouse,
1475
+ pi: this.#config.pi,
1476
+ env,
1477
+ ...fragment !== void 0 ? { policyFragment: fragment, fragmentDir: this.#config.fragmentDir } : {},
1478
+ ...input.enable ? { enable: input.enable } : {},
1479
+ ...input.name ? { name: input.name } : {}
1480
+ };
1481
+ let policy;
1482
+ try {
1483
+ policy = composePolicy(spec);
1484
+ } catch (error) {
1485
+ return { ok: false, message: error.message };
1486
+ }
1487
+ const store = readStore(this.#config.agentRoot, {
1488
+ ownership: this.#config.agentRootManaged ? "managed" : "external"
1489
+ });
1490
+ const capabilities = assessAgentCapabilities({
1491
+ store,
1492
+ sandboxed: input.mode === "sandboxed",
1493
+ ...fragment !== void 0 ? { fragment } : {},
1494
+ providerAuth,
1495
+ keychainPath
1496
+ });
1497
+ const session = {
1498
+ id: input.id,
1499
+ name: input.name?.trim() || defaultName(input.cwd, input.id),
1500
+ cwd: input.cwd,
1501
+ state: "starting",
1502
+ mode: input.mode,
1503
+ isolation: policy.isolation,
1504
+ command: describe(policy),
1505
+ scratch: isScratch(this.#config, input.cwd),
1506
+ messageCount: 0,
1507
+ pending: 0,
1508
+ contextPercent: null,
1509
+ tokensTotal: null,
1510
+ cost: null,
1511
+ dialogs: [],
1512
+ // Policy warnings, credential warnings, and the self-hosting hazard (issues #28, #25).
1513
+ // A session that starts and cannot think, or cannot use its own identity, is one failure this
1514
+ // surfaces (C13, C15); a session pointed at the checkout Pi Central is running from is
1515
+ // another, and it is warned about rather than refused — the user may have a good reason, and
1516
+ // the consequences are what they need in order to judge (issue #25).
1517
+ warnings: [
1518
+ ...policy.warnings,
1519
+ ...capabilities.warnings,
1520
+ ...ownInstallationWarning(input.cwd, this.#installationRoot) !== void 0 ? [ownInstallationWarning(input.cwd, this.#installationRoot)] : []
1521
+ ],
1522
+ startedAt: Date.now(),
1523
+ ...input.sessionPath ? { sessionFile: input.sessionPath } : {}
1524
+ };
1525
+ this.#sessions.set(session.id, session);
1526
+ const rpc = new RpcProcess(this.#buildLaunch(policy.launch, input.sessionPath));
1527
+ this.#processes.set(session.id, rpc);
1528
+ rpc.on("record", (record) => this.#onRecord(session.id, record));
1529
+ rpc.on("failed", (error) => {
1530
+ session.state = "failed";
1531
+ session.error = error.message;
1532
+ this.#patch(session.id);
1533
+ this.emit("error", session.id, error.message);
1534
+ });
1535
+ rpc.on("exit", ({ code, signal, stderr }) => {
1536
+ if (session.state === "stopped") return;
1537
+ session.state = "failed";
1538
+ session.error = describeExitFailure(code, signal, stderr);
1539
+ session.dialogs = [];
1540
+ this.#patch(session.id);
1541
+ this.emit("error", session.id, session.error);
1542
+ });
1543
+ rpc.start();
1544
+ this.#patch(session.id);
1545
+ void this.#refreshState(session.id);
1546
+ return { ok: true, session };
1547
+ }
1548
+ /**
1549
+ * A resumed session reopens its file (`--session <path>`); a new session is left to Pi's
1550
+ * default naming in its workspace.
1551
+ *
1552
+ * `--session` is appended after the `--` separator, so it is a *pi* argument, not a
1553
+ * safehouse one. The only value interpolated is a path validated to exist by
1554
+ * `checkResumable`; the argument shape is fixed.
1555
+ */
1556
+ #buildLaunch(launch, sessionPath) {
1557
+ if (!sessionPath) return launch;
1558
+ return { ...launch, args: [...launch.args, "--session", sessionPath] };
1559
+ }
1560
+ async #refreshState(id) {
1561
+ const session = this.#sessions.get(id);
1562
+ const rpc = this.#processes.get(id);
1563
+ if (!session || !rpc) return;
1564
+ try {
1565
+ const state = await rpc.send({ type: "get_state" }, 2e4);
1566
+ const model = state.model;
1567
+ session.model = model?.id;
1568
+ session.provider = model?.provider;
1569
+ session.thinkingLevel = state.thinkingLevel;
1570
+ if (typeof state.sessionFile === "string") session.sessionFile = state.sessionFile;
1571
+ if (typeof state.sessionId === "string" && state.sessionId) session.sessionId = state.sessionId;
1572
+ if (typeof state.sessionName === "string" && state.sessionName) session.name = state.sessionName;
1573
+ session.messageCount = Number(state.messageCount ?? session.messageCount);
1574
+ session.pending = Number(state.pendingMessageCount ?? session.pending);
1575
+ if (session.state === "starting") session.state = state.isStreaming ? "streaming" : "idle";
1576
+ session.error = void 0;
1577
+ this.#patch(id);
1578
+ void this.#refreshStats(id);
1579
+ void this.#loadTranscript(id);
1580
+ } catch (error) {
1581
+ session.state = "failed";
1582
+ session.error = `${error.message}${rpc.stderrTail ? `
1583
+ ${rpc.stderrTail}` : ""}`;
1584
+ this.#patch(id);
1585
+ }
1586
+ }
1587
+ /**
1588
+ * Fetch a session's conversation from Pi.
1589
+ *
1590
+ * **Authoritative and current, and nothing is cached.** That is the whole design, and it is a
1591
+ * correction: an earlier version fetched once at session start, cached the result, and replayed
1592
+ * the cache to every client that connected later. For a session started fresh that cache held
1593
+ * the empty conversation from its first instant, so a reconnect REPLACED a correct transcript
1594
+ * with an empty one — and a one-shot guard meant it could never recover. The UI then showed
1595
+ * "Loading this session's history…" indefinitely, because Pi reported messages that the client
1596
+ * would never receive.
1597
+ *
1598
+ * A cache of a conversation is stale the moment it is taken. Re-fetching costs one sub-
1599
+ * millisecond round trip per session per connection, which is a price worth paying for being
1600
+ * right.
1601
+ *
1602
+ * Returns a result rather than throwing, because callers must distinguish "the conversation is
1603
+ * empty" from "the conversation could not be read" — the first is fine and the second must not
1604
+ * be presented as an eternal spinner.
1605
+ */
1606
+ async fetchTranscript(id) {
1607
+ const rpc = this.#processes.get(id);
1608
+ if (!rpc) return { ok: false, message: "this session is not running" };
1609
+ try {
1610
+ const data = await rpc.send({ type: "get_messages" }, 3e4);
1611
+ return {
1612
+ ok: true,
1613
+ messages: (data?.messages ?? []).filter(
1614
+ (message) => typeof message === "object" && message !== null
1615
+ )
1616
+ };
1617
+ } catch (error) {
1618
+ return { ok: false, message: error.message };
1619
+ }
1620
+ }
1621
+ /**
1622
+ * Load a session's conversation once, when it starts or resumes.
1623
+ *
1624
+ * **This is the fix for a resumed session showing an empty transcript.** Pi loads a resumed
1625
+ * session's history into the model's context but does NOT re-emit it as events, so a UI built
1626
+ * only from `record` events shows "No messages yet" for a session with hundreds of messages —
1627
+ * which is precisely the case US-5 exists to serve.
1628
+ *
1629
+ * Re-emitted as `message_end` records rather than through a new event type, so history and a
1630
+ * live stream take the SAME code path in the UI reducer. There is one renderer for one
1631
+ * conversation; two is how the past and the present end up looking different.
1632
+ *
1633
+ * The once-only guard covers this automatic path to avoid duplicating the conversation on every
1634
+ * state refresh. Clients that connect later call `fetchTranscript` directly instead, so they
1635
+ * always get the CURRENT conversation rather than this session's opening state.
1636
+ */
1637
+ async #loadTranscript(id) {
1638
+ const session = this.#sessions.get(id);
1639
+ if (!session || session.transcriptLoaded) return;
1640
+ const result = await this.fetchTranscript(id);
1641
+ if (!result.ok) {
1642
+ session.warnings = [
1643
+ ...session.warnings,
1644
+ `Could not load this session's earlier messages: ${result.message}`
1645
+ ];
1646
+ this.emit("transcriptError", id, result.message);
1647
+ this.#patch(id);
1648
+ return;
1649
+ }
1650
+ session.transcriptLoaded = true;
1651
+ this.emit("transcript", id, result.messages);
1652
+ }
1653
+ async #refreshStats(id) {
1654
+ const session = this.#sessions.get(id);
1655
+ const rpc = this.#processes.get(id);
1656
+ if (!session || !rpc || !rpc.running) return;
1657
+ try {
1658
+ const stats = await rpc.send({ type: "get_session_stats" }, 2e4);
1659
+ const ctx = stats.contextUsage;
1660
+ const tokens = stats.tokens;
1661
+ session.contextPercent = ctx?.percent ?? null;
1662
+ session.tokensTotal = tokens?.total ?? null;
1663
+ session.cost = typeof stats.cost === "number" ? stats.cost : null;
1664
+ this.#patch(id);
1665
+ } catch {
1666
+ }
1667
+ }
1668
+ #onRecord(id, record) {
1669
+ const session = this.#sessions.get(id);
1670
+ if (!session) return;
1671
+ const effect = interpretRecord(session, record);
1672
+ if (effect.dialog) session.dialogs.push(effect.dialog);
1673
+ if (effect.patch) Object.assign(session, effect.patch);
1674
+ if (effect.refreshStats) void this.#refreshStats(id);
1675
+ this.emit("record", id, record);
1676
+ if (record.type === "queue_update") {
1677
+ this.emit(
1678
+ "queue",
1679
+ id,
1680
+ asTextArray(record.steering),
1681
+ asTextArray(record.followUp)
1682
+ );
1683
+ }
1684
+ this.#patch(id);
1685
+ }
1686
+ answerDialog(id, dialogId, answer) {
1687
+ const session = this.#sessions.get(id);
1688
+ const rpc = this.#processes.get(id);
1689
+ if (!session || !rpc) return;
1690
+ const payload = { type: "extension_ui_response", id: dialogId };
1691
+ if ("cancelled" in answer) payload.cancelled = true;
1692
+ else if ("confirmed" in answer) payload.confirmed = answer.confirmed;
1693
+ else payload.value = answer.value;
1694
+ rpc.write(payload);
1695
+ session.dialogs = session.dialogs.filter((dialog) => dialog.id !== dialogId);
1696
+ if (session.dialogs.length === 0 && session.state === "awaiting-approval") session.state = "idle";
1697
+ this.#patch(id);
1698
+ }
1699
+ /**
1700
+ * Send a prompt. The command shape is closed; the message is the only free value.
1701
+ *
1702
+ * `behavior` decides what a message means mid-run, and it is the caller's choice rather than
1703
+ * this method's: Pi REJECTS a streaming `prompt` with no `streamingBehavior` (`"Agent is
1704
+ * already processing. Specify streamingBehavior ('steer' or 'followUp')"`), so defaulting it
1705
+ * silently would decide for the user something the composer is there to ask (issue #3). When
1706
+ * idle there is nothing to steer into, so the flag is dropped rather than sent.
1707
+ */
1708
+ async prompt(id, message, behavior) {
1709
+ const rpc = this.#processes.get(id);
1710
+ const session = this.#sessions.get(id);
1711
+ if (!rpc || !session) throw new Error(`no such session: ${id}`);
1712
+ if (session.state === "streaming") {
1713
+ await rpc.send({ type: "prompt", message, streamingBehavior: behavior ?? "followUp" }, 6e4);
1714
+ } else {
1715
+ await rpc.send({ type: "prompt", message }, 6e4);
1716
+ }
1717
+ }
1718
+ /**
1719
+ * Empty Pi's queue, and optionally stop the run, in that order.
1720
+ *
1721
+ * **The order is the whole point.** Pi's `clear_queue` returns the queued texts, so clearing
1722
+ * first is what makes them recoverable: abort-then-clear would let the run settle and, in the
1723
+ * window before the queue was emptied, Pi could begin delivering a queued message as a real
1724
+ * turn. Clearing first removes the queue before anything else can act on it, and the returned
1725
+ * texts are broadcast so the composer restores exactly what was removed instead of guessing.
1726
+ *
1727
+ * `abort` is sent directly rather than via `this.abort` so the pair cannot be reordered by a
1728
+ * later refactor.
1729
+ *
1730
+ * Returns the cleared texts, so a caller that wants them does not have to wait for the event.
1731
+ */
1732
+ async clearQueue(id, andStop) {
1733
+ const rpc = this.#processes.get(id);
1734
+ if (!rpc) throw new Error(`no such session: ${id}`);
1735
+ const result = await clearQueueVia((command, timeout) => rpc.send(command, timeout), andStop);
1736
+ this.emit("queueCleared", id, result.steering, result.followUp);
1737
+ return result;
1738
+ }
1739
+ async abort(id) {
1740
+ const rpc = this.#processes.get(id);
1741
+ if (rpc) await rpc.send({ type: "abort" }, 3e4);
1742
+ }
1743
+ async rename(id, name) {
1744
+ const rpc = this.#processes.get(id);
1745
+ const session = this.#sessions.get(id);
1746
+ if (!rpc || !session) throw new Error(`no such session: ${id}`);
1747
+ await rpc.send({ type: "set_session_name", name }, 2e4);
1748
+ session.name = name;
1749
+ this.#patch(id);
1750
+ }
1751
+ /**
1752
+ * Ask Pi which models this session can use.
1753
+ *
1754
+ * Returns a result rather than throwing, for the same reason `fetchTranscript` does: the caller
1755
+ * needs "there are none" and "the question failed" to be different events. A list rendered as
1756
+ * empty when the command errored would say the session has no models, which is a different and
1757
+ * false claim (C14's family).
1758
+ */
1759
+ async listModels(id) {
1760
+ const rpc = this.#processes.get(id);
1761
+ if (!rpc) return { ok: false, message: "this session is not running" };
1762
+ try {
1763
+ const data = await rpc.send({ type: "get_available_models" }, 3e4);
1764
+ const models = (data?.models ?? []).filter((model) => typeof model === "object" && model !== null).map((model) => ({
1765
+ provider: String(model["provider"] ?? ""),
1766
+ id: String(model["id"] ?? ""),
1767
+ ...typeof model["name"] === "string" ? { name: model["name"] } : {},
1768
+ ...typeof model["reasoning"] === "boolean" ? { reasoning: model["reasoning"] } : {}
1769
+ })).filter((model) => model.provider !== "" && model.id !== "");
1770
+ return { ok: true, models };
1771
+ } catch (error) {
1772
+ return { ok: false, message: error.message };
1773
+ }
1774
+ }
1775
+ /**
1776
+ * Change a session's model, then re-read the state Pi reports.
1777
+ *
1778
+ * **The session's fields are not set optimistically.** Pi validates the choice (an unconfigured
1779
+ * provider fails with `No API key for <provider>/<model>`), persists it to the transcript, and
1780
+ * re-applies the thinking level for the new model. Setting `session.model` from the request
1781
+ * would therefore display a change that may not have happened, and would miss the thinking-level
1782
+ * adjustment — the two ways this can disagree with Pi.
1783
+ *
1784
+ * A refusal is thrown, not swallowed: the caller broadcasts it, so it appears as an error naming
1785
+ * the reason rather than as a control that silently did nothing.
1786
+ */
1787
+ async setModel(id, provider, modelId) {
1788
+ const rpc = this.#processes.get(id);
1789
+ const session = this.#sessions.get(id);
1790
+ if (!rpc || !session) throw new Error(`no such session: ${id}`);
1791
+ await rpc.send({ type: "set_model", provider, modelId }, 3e4);
1792
+ await this.#refreshState(id);
1793
+ }
1794
+ /** Which thinking levels the CURRENT model supports. Per-model, so it must be re-read on a switch. */
1795
+ async listThinkingLevels(id) {
1796
+ const rpc = this.#processes.get(id);
1797
+ if (!rpc) return { ok: false, message: "this session is not running" };
1798
+ try {
1799
+ const data = await rpc.send({ type: "get_available_thinking_levels" }, 3e4);
1800
+ return { ok: true, levels: (data?.levels ?? []).map((level) => String(level)) };
1801
+ } catch (error) {
1802
+ return { ok: false, message: error.message };
1803
+ }
1804
+ }
1805
+ /**
1806
+ * Change the thinking level, then re-read the state Pi reports.
1807
+ *
1808
+ * The same reasoning as `setModel`: Pi may reject a level the model does not support, so the
1809
+ * value shown must be the one Pi confirms rather than the one that was asked for.
1810
+ */
1811
+ async setThinkingLevel(id, level) {
1812
+ const rpc = this.#processes.get(id);
1813
+ const session = this.#sessions.get(id);
1814
+ if (!rpc || !session) throw new Error(`no such session: ${id}`);
1815
+ await rpc.send({ type: "set_thinking_level", level }, 2e4);
1816
+ await this.#refreshState(id);
1817
+ }
1818
+ async stop(id) {
1819
+ const session = this.#sessions.get(id);
1820
+ const rpc = this.#processes.get(id);
1821
+ if (!session || !rpc) return;
1822
+ session.state = "stopped";
1823
+ session.dialogs = [];
1824
+ this.#patch(id);
1825
+ await rpc.stop();
1826
+ this.#processes.delete(id);
1827
+ }
1828
+ async stopAll() {
1829
+ await Promise.all([...this.#processes.keys()].map((id) => this.stop(id)));
1830
+ }
1831
+ /**
1832
+ * List a session's working directory (issue #8, ADR-0007).
1833
+ *
1834
+ * Synchronous and plain `fs`, at the orchestrator's UID — no sandbox, no RPC round trip. The
1835
+ * confinement is the session's own `cwd`, which is why this goes through the supervisor: it is the
1836
+ * component that knows what that cwd is, so there is exactly one answer to "what is this session
1837
+ * confined to".
1838
+ *
1839
+ * An unknown session is REFUSED rather than resolved against some other root. Falling back would
1840
+ * mean a stale session id browsed a directory that had nothing to do with it.
1841
+ */
1842
+ listFiles(id, path, options = {}) {
1843
+ const session = this.#sessions.get(id);
1844
+ if (!session) {
1845
+ return {
1846
+ ok: false,
1847
+ error: { reason: "not-found", message: `no session ${id}, so there is no working directory to list` }
1848
+ };
1849
+ }
1850
+ return listDirectory(session.cwd, path, options);
1851
+ }
1852
+ /** Read one text file from a session's working directory, confined the same way. */
1853
+ readFile(id, path) {
1854
+ const session = this.#sessions.get(id);
1855
+ if (!session) {
1856
+ return {
1857
+ ok: false,
1858
+ error: { reason: "not-found", message: `no session ${id}, so there is no working directory to read from` }
1859
+ };
1860
+ }
1861
+ return readFileText(session.cwd, path);
1862
+ }
1863
+ /**
1864
+ * Where this Pi Central installation lives, resolved once.
1865
+ *
1866
+ * Overridable so a test can supply a path rather than depending on where the test file happens to
1867
+ * be — the same seam `#config` provides for everything else. `undefined` means the walk could not
1868
+ * identify it, in which case no session is warned: an unidentifiable path is not evidence of
1869
+ * safety, but a warning on every session would be noise, so the honest report is the absence of a
1870
+ * claim. The startup banner says so instead, once.
1871
+ */
1872
+ #installationRoot;
1873
+ setInstallationRoot(path) {
1874
+ this.#installationRoot = path;
1875
+ }
1876
+ #patch(id) {
1877
+ const session = this.#sessions.get(id);
1878
+ if (session) this.emit("update", session);
1879
+ }
1880
+ };
1881
+ function describeExitFailure(code, signal, stderr) {
1882
+ const base = `pi exited (code=${code ?? "-"}, signal=${signal ?? "-"})${stderr ? `
1883
+ ${stderr}` : ""}`;
1884
+ if (!/sandbox_apply|Operation not permitted/i.test(stderr)) return base;
1885
+ return `${base}
1886
+
1887
+ This looks like Seatbelt refusing to nest (C1). The orchestrator must run from a plain terminal, not inside another sandbox, editor task, or agent harness -- because \`safehouse\` applies its policy with \`sandbox-exec\`, and that cannot run inside an existing sandbox.`;
1888
+ }
1889
+ function defaultName(cwd, id) {
1890
+ const base = cwd.split("/").filter(Boolean).pop();
1891
+ return base && base.length > 0 ? base : id;
1892
+ }
1893
+ function asTextArray(value) {
1894
+ return Array.isArray(value) ? value.map((item) => String(item)) : [];
1895
+ }
1896
+ async function clearQueueVia(send, andStop) {
1897
+ const cleared = await send({ type: "clear_queue" }, 2e4);
1898
+ const result = {
1899
+ steering: asTextArray(cleared?.steering),
1900
+ followUp: asTextArray(cleared?.followUp)
1901
+ };
1902
+ if (andStop) await send({ type: "abort" }, 3e4);
1903
+ return result;
1904
+ }
1905
+
1906
+ // packages/orchestrator/src/transport.ts
1907
+ import { createServer } from "node:http";
1908
+ import { WebSocketServer } from "ws";
1909
+
1910
+ // packages/orchestrator/src/package-meta.ts
1911
+ import { existsSync as existsSync5, readFileSync as readFileSync5 } from "node:fs";
1912
+ import { dirname, join as join9, parse } from "node:path";
1913
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
1914
+ var PACKAGE_NAME = "pi-central";
1915
+ function findOwnPackage(from = import.meta.url) {
1916
+ let directory = dirname(fileURLToPath2(from));
1917
+ for (; ; ) {
1918
+ const candidate = join9(directory, "package.json");
1919
+ if (existsSync5(candidate)) {
1920
+ const parsed = readPackageJson(candidate);
1921
+ if (parsed?.name === PACKAGE_NAME && typeof parsed.version === "string") {
1922
+ return { path: candidate, root: directory, name: parsed.name, version: parsed.version };
1923
+ }
1924
+ }
1925
+ const parent = dirname(directory);
1926
+ if (parent === directory || directory === parse(directory).root) return void 0;
1927
+ directory = parent;
1928
+ }
1929
+ }
1930
+ function readPackageJson(path) {
1931
+ try {
1932
+ const parsed = JSON.parse(readFileSync5(path, "utf8"));
1933
+ if (typeof parsed !== "object" || parsed === null) return void 0;
1934
+ return parsed;
1935
+ } catch {
1936
+ return void 0;
1937
+ }
1938
+ }
1939
+
1940
+ // packages/orchestrator/src/registry.ts
1941
+ import { createReadStream, readdirSync as readdirSync3, statSync as statSync5 } from "node:fs";
1942
+ import { join as join10 } from "node:path";
1943
+ var KNOWN_SESSION_VERSIONS = /* @__PURE__ */ new Set([3]);
1944
+ var HEAD_BYTES = 256 * 1024;
1945
+ function decodeSessionDir(dirName) {
1946
+ const inner = dirName.replace(/^--/, "").replace(/--$/, "");
1947
+ return `/${inner.replace(/-/g, "/")}`;
1948
+ }
1949
+ async function readHead(path) {
1950
+ return new Promise((resolve8) => {
1951
+ const chunks = [];
1952
+ let size = 0;
1953
+ const stream = createReadStream(path, { start: 0, end: HEAD_BYTES - 1 });
1954
+ stream.on("data", (chunk) => {
1955
+ chunks.push(chunk);
1956
+ size += chunk.length;
1957
+ });
1958
+ stream.on("error", () => resolve8(""));
1959
+ stream.on("close", () => resolve8(Buffer.concat(chunks, size).toString("utf8")));
1960
+ });
1961
+ }
1962
+ function parseHead(text) {
1963
+ const lines = text.split("\n");
1964
+ let header = null;
1965
+ let name;
1966
+ let firstMessage = "";
1967
+ let messageCount = 0;
1968
+ for (const line of lines) {
1969
+ if (!line) continue;
1970
+ let record;
1971
+ try {
1972
+ record = JSON.parse(line);
1973
+ } catch {
1974
+ if (!header) continue;
1975
+ break;
1976
+ }
1977
+ if (!header) {
1978
+ if (record.type !== "session") return null;
1979
+ if (typeof record.id !== "string" || typeof record.cwd !== "string") return null;
1980
+ header = {
1981
+ type: "session",
1982
+ version: Number(record.version ?? 0),
1983
+ id: record.id,
1984
+ timestamp: String(record.timestamp ?? ""),
1985
+ cwd: record.cwd,
1986
+ ...typeof record.parentSession === "string" ? { parentSession: record.parentSession } : {}
1987
+ };
1988
+ continue;
1989
+ }
1990
+ if (record.type === "session_info" && typeof record.name === "string") {
1991
+ name = record.name.trim() || void 0;
1992
+ continue;
1993
+ }
1994
+ if (record.type === "message") {
1995
+ messageCount += 1;
1996
+ if (!firstMessage) {
1997
+ const message = record.message;
1998
+ if (message?.role === "user") firstMessage = flattenContent(message.content).slice(0, 200);
1999
+ }
2000
+ }
2001
+ }
2002
+ return header ? { header, name, firstMessage, messageCount } : null;
2003
+ }
2004
+ function flattenContent(content) {
2005
+ if (typeof content === "string") return content;
2006
+ if (Array.isArray(content)) {
2007
+ return content.map((part) => {
2008
+ if (typeof part === "string") return part;
2009
+ if (part && typeof part === "object" && "text" in part) return String(part.text);
2010
+ return "";
2011
+ }).join(" ");
2012
+ }
2013
+ return "";
2014
+ }
2015
+ async function discover(config, limit = 500) {
2016
+ const sessions = [];
2017
+ const skipped = [];
2018
+ let projectDirs;
2019
+ try {
2020
+ projectDirs = readdirSync3(config.sessionsRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
2021
+ } catch {
2022
+ return { sessions, total: 0, skipped };
2023
+ }
2024
+ for (const projectDir of projectDirs) {
2025
+ const projectPath = join10(config.sessionsRoot, projectDir);
2026
+ let entries;
2027
+ try {
2028
+ entries = readdirSync3(projectPath, { withFileTypes: true });
2029
+ } catch {
2030
+ continue;
2031
+ }
2032
+ for (const entry of entries) {
2033
+ if (!entry.name.endsWith(".jsonl")) continue;
2034
+ if (!entry.isFile()) continue;
2035
+ const path = join10(projectPath, entry.name);
2036
+ let stats;
2037
+ try {
2038
+ stats = statSync5(path);
2039
+ } catch {
2040
+ continue;
2041
+ }
2042
+ const parsed = parseHead(await readHead(path));
2043
+ if (!parsed) {
2044
+ skipped.push({ path, reason: "not a Pi session file (no valid header)" });
2045
+ continue;
2046
+ }
2047
+ if (!KNOWN_SESSION_VERSIONS.has(parsed.header.version)) {
2048
+ skipped.push({ path, reason: `unknown session version ${parsed.header.version}` });
2049
+ continue;
2050
+ }
2051
+ const cwd = parsed.header.cwd || decodeSessionDir(projectDir);
2052
+ sessions.push({
2053
+ path,
2054
+ id: parsed.header.id,
2055
+ cwd,
2056
+ ...parsed.name ? { name: parsed.name } : {},
2057
+ created: parsed.header.timestamp,
2058
+ modifiedMs: stats.mtimeMs,
2059
+ bytes: stats.size,
2060
+ messageCount: parsed.messageCount,
2061
+ firstMessage: parsed.firstMessage,
2062
+ cwdExists: directoryExists(cwd),
2063
+ scratch: isScratch(config, cwd)
2064
+ });
2065
+ }
2066
+ }
2067
+ sessions.sort((a, b) => b.modifiedMs - a.modifiedMs);
2068
+ return { sessions: sessions.slice(0, limit), total: sessions.length, skipped };
2069
+ }
2070
+ function directoryExists(dir) {
2071
+ try {
2072
+ return statSync5(dir).isDirectory();
2073
+ } catch {
2074
+ return false;
2075
+ }
2076
+ }
2077
+
2078
+ // packages/orchestrator/src/static-ui.ts
2079
+ import { createReadStream as createReadStream2, existsSync as existsSync6, statSync as statSync6 } from "node:fs";
2080
+ import { extname, join as join11, posix, resolve as resolve7, sep as sep4 } from "node:path";
2081
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
2082
+ var UI_CSP = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
2083
+ var CONTENT_TYPES = {
2084
+ ".html": "text/html; charset=utf-8",
2085
+ ".js": "text/javascript; charset=utf-8",
2086
+ ".mjs": "text/javascript; charset=utf-8",
2087
+ ".css": "text/css; charset=utf-8",
2088
+ ".json": "application/json; charset=utf-8",
2089
+ ".map": "application/json; charset=utf-8",
2090
+ ".svg": "image/svg+xml",
2091
+ ".png": "image/png",
2092
+ ".jpg": "image/jpeg",
2093
+ ".jpeg": "image/jpeg",
2094
+ ".webp": "image/webp",
2095
+ ".gif": "image/gif",
2096
+ ".ico": "image/x-icon",
2097
+ ".woff": "font/woff",
2098
+ ".woff2": "font/woff2",
2099
+ ".txt": "text/plain; charset=utf-8"
2100
+ };
2101
+ function resolveUiDistDir(env = process.env, from = import.meta.url) {
2102
+ const explicit = env["PI_CENTRAL_UI_DIST"];
2103
+ if (explicit !== void 0 && explicit !== "") return resolve7(explicit);
2104
+ const own = findOwnPackage(from);
2105
+ if (own === void 0) {
2106
+ return fileURLToPath3(new URL("../../ui/dist", from));
2107
+ }
2108
+ const packaged = join11(own.root, "dist", "ui");
2109
+ const workspace = join11(own.root, "packages", "ui", "dist");
2110
+ const fromSource = runningFromSource(from);
2111
+ const candidates = fromSource ? [workspace, packaged] : [packaged, workspace];
2112
+ for (const candidate of candidates) {
2113
+ if (hasBuiltUi(candidate)) return candidate;
2114
+ }
2115
+ return fromSource ? workspace : packaged;
2116
+ }
2117
+ function runningFromSource(from) {
2118
+ return /[\\/]packages[\\/][^\\/]+[\\/]src[\\/]/.test(fileURLToPath3(from));
2119
+ }
2120
+ function isInside3(root, candidate) {
2121
+ const rootPath = resolve7(root);
2122
+ const target = resolve7(candidate);
2123
+ if (target === rootPath) return true;
2124
+ return target.startsWith(rootPath.endsWith(sep4) ? rootPath : rootPath + sep4);
2125
+ }
2126
+ function resolveStaticPath(root, requestTarget) {
2127
+ const rootPath = resolve7(root);
2128
+ const target = requestTarget.split("?")[0].split("#")[0];
2129
+ let decoded;
2130
+ try {
2131
+ decoded = decodeURIComponent(target);
2132
+ } catch {
2133
+ return { kind: "denied", reason: "the request target is not valid percent-encoding" };
2134
+ }
2135
+ if (decoded.includes("\0")) return { kind: "denied", reason: "the request target contains a NUL byte" };
2136
+ if (!decoded.startsWith("/")) return { kind: "denied", reason: "the request target is not an absolute path" };
2137
+ if (decoded.split("/").includes("..")) {
2138
+ return { kind: "denied", reason: "the request target contains a '..' segment" };
2139
+ }
2140
+ const relative = posix.normalize(decoded).replace(/^\/+/, "");
2141
+ const candidate = resolve7(rootPath, relative);
2142
+ if (!isInside3(rootPath, candidate)) {
2143
+ return { kind: "denied", reason: "the request target resolves outside the served UI root" };
2144
+ }
2145
+ return { kind: "file", path: candidate };
2146
+ }
2147
+ function createUiRequestHandler(options) {
2148
+ const root = resolve7(options.root);
2149
+ const csp = options.csp ?? UI_CSP;
2150
+ const indexPath = resolve7(root, "index.html");
2151
+ return (req, res) => {
2152
+ const method = req.method ?? "GET";
2153
+ if (method !== "GET" && method !== "HEAD") {
2154
+ res.writeHead(405, {
2155
+ "content-type": "text/plain; charset=utf-8",
2156
+ allow: "GET, HEAD",
2157
+ "content-security-policy": csp
2158
+ });
2159
+ res.end("this listener serves GET and HEAD only\n");
2160
+ return;
2161
+ }
2162
+ const resolution = resolveStaticPath(root, req.url ?? "/");
2163
+ if (resolution.kind === "denied") {
2164
+ sendText(res, method, 403, `refused: ${resolution.reason}
2165
+ `, csp);
2166
+ return;
2167
+ }
2168
+ const path = resolution.path;
2169
+ if (isReadableFile(path)) {
2170
+ sendFile(res, method, path, csp);
2171
+ return;
2172
+ }
2173
+ const lastSegment = posix.basename(path);
2174
+ const looksLikeRoute = !lastSegment.includes(".") || path === root;
2175
+ if (looksLikeRoute) {
2176
+ if (isReadableFile(indexPath)) {
2177
+ sendFile(res, method, indexPath, csp);
2178
+ return;
2179
+ }
2180
+ sendText(
2181
+ res,
2182
+ method,
2183
+ 500,
2184
+ `The UI has not been built, so there is nothing to serve.
2185
+
2186
+ Build it with:
2187
+ npm run ui:build
2188
+
2189
+ (looked for ${indexPath})
2190
+ `,
2191
+ csp
2192
+ );
2193
+ return;
2194
+ }
2195
+ sendText(res, method, 404, `no such file under the served UI root: ${posix.basename(path)}
2196
+ `, csp);
2197
+ };
2198
+ }
2199
+ function isReadableFile(path) {
2200
+ try {
2201
+ return statSync6(path).isFile();
2202
+ } catch {
2203
+ return false;
2204
+ }
2205
+ }
2206
+ function sendText(res, method, status, body, csp) {
2207
+ const bytes = Buffer.byteLength(body);
2208
+ res.writeHead(status, {
2209
+ "content-type": "text/plain; charset=utf-8",
2210
+ "content-length": String(bytes),
2211
+ "x-content-type-options": "nosniff",
2212
+ "content-security-policy": csp
2213
+ });
2214
+ if (method === "HEAD") res.end();
2215
+ else res.end(body);
2216
+ }
2217
+ function sendFile(res, method, path, csp) {
2218
+ const info = statSync6(path);
2219
+ const type = CONTENT_TYPES[extname(path).toLowerCase()] ?? "application/octet-stream";
2220
+ const immutable = path.includes(`${sep4}assets${sep4}`);
2221
+ res.writeHead(200, {
2222
+ "content-type": type,
2223
+ "content-length": String(info.size),
2224
+ "content-security-policy": csp,
2225
+ "x-content-type-options": "nosniff",
2226
+ "cache-control": immutable ? "public, max-age=31536000, immutable" : "no-cache"
2227
+ });
2228
+ if (method === "HEAD") {
2229
+ res.end();
2230
+ return;
2231
+ }
2232
+ const stream = createReadStream2(path);
2233
+ stream.on("error", () => res.destroy());
2234
+ stream.pipe(res);
2235
+ }
2236
+ function hasBuiltUi(root) {
2237
+ return existsSync6(resolve7(root, "index.html"));
2238
+ }
2239
+
2240
+ // packages/orchestrator/src/transport.ts
2241
+ var KNOWN_CONTROL_KINDS = /* @__PURE__ */ new Set([
2242
+ "list",
2243
+ "start",
2244
+ "prompt",
2245
+ "clearQueue",
2246
+ "abort",
2247
+ "stop",
2248
+ "rename",
2249
+ "setModel",
2250
+ "listModels",
2251
+ "setThinkingLevel",
2252
+ "listThinkingLevels",
2253
+ "answerDialog",
2254
+ "resume",
2255
+ "listFiles",
2256
+ "readFile"
2257
+ ]);
2258
+ function parseControl(raw) {
2259
+ let parsed;
2260
+ try {
2261
+ parsed = JSON.parse(raw);
2262
+ } catch {
2263
+ return { error: "not JSON" };
2264
+ }
2265
+ if (typeof parsed !== "object" || parsed === null) return { error: "not an object" };
2266
+ const envelope = parsed;
2267
+ if (envelope.dir !== "client\u2192orchestrator") {
2268
+ return { error: `wrong direction: expected 'client\u2192orchestrator', got '${String(envelope.dir)}'` };
2269
+ }
2270
+ if (envelope.protocol !== PROTOCOL_VERSION) {
2271
+ return { error: `protocol mismatch: expected ${PROTOCOL_VERSION}, got ${String(envelope.protocol)}` };
2272
+ }
2273
+ const payload = envelope.payload;
2274
+ if (typeof payload !== "object" || payload === null || typeof payload.kind !== "string") {
2275
+ return { error: "payload has no kind" };
2276
+ }
2277
+ return payload;
2278
+ }
2279
+ var Transport = class {
2280
+ #supervisor;
2281
+ #config;
2282
+ #options;
2283
+ #server;
2284
+ #wss;
2285
+ #clients = /* @__PURE__ */ new Set();
2286
+ #address;
2287
+ #install;
2288
+ constructor(supervisor, config, options = {}) {
2289
+ this.#supervisor = supervisor;
2290
+ this.#config = config;
2291
+ this.#options = {
2292
+ host: options.host ?? "127.0.0.1",
2293
+ port: options.port ?? 0,
2294
+ maxClients: options.maxClients ?? 8,
2295
+ uiDir: options.uiDir === void 0 ? resolveUiDistDir() : options.uiDir
2296
+ };
2297
+ }
2298
+ get address() {
2299
+ return this.#address;
2300
+ }
2301
+ /** The UI directory being served, or `null` when the socket is the only surface. */
2302
+ get uiDir() {
2303
+ return this.#options.uiDir;
2304
+ }
2305
+ /** Bind and start serving. Resolves with the actual address (port 0 → a real port). */
2306
+ async listen() {
2307
+ if (this.#server) throw new Error("transport is already listening");
2308
+ const uiHandler = this.#options.uiDir === null ? void 0 : createUiRequestHandler({ root: this.#options.uiDir });
2309
+ const server = createServer((req, res) => {
2310
+ if (uiHandler) {
2311
+ uiHandler(req, res);
2312
+ return;
2313
+ }
2314
+ res.writeHead(426, { "content-type": "text/plain" });
2315
+ res.end("Pi Central speaks WebSocket only, and no UI directory is configured.\n");
2316
+ });
2317
+ const wss = new WebSocketServer({ server, maxPayload: 8 * 1024 * 1024 });
2318
+ this.#server = server;
2319
+ this.#wss = wss;
2320
+ wss.on("connection", (socket) => this.#onConnection(socket));
2321
+ await new Promise((resolve8, reject) => {
2322
+ server.once("error", reject);
2323
+ server.listen(this.#options.port, this.#options.host, () => {
2324
+ server.off("error", reject);
2325
+ resolve8();
2326
+ });
2327
+ });
2328
+ const addr = server.address();
2329
+ if (addr === null || typeof addr === "string") throw new Error("unexpected server address");
2330
+ this.#address = {
2331
+ host: this.#options.host,
2332
+ port: addr.port,
2333
+ url: `ws://${this.#options.host}:${addr.port}`
2334
+ };
2335
+ return this.#address;
2336
+ }
2337
+ #onConnection(socket) {
2338
+ if (this.#clients.size >= this.#options.maxClients) {
2339
+ this.#send(socket, { kind: "error", message: `too many clients (max ${this.#options.maxClients})` });
2340
+ socket.close(1013, "too many clients");
2341
+ return;
2342
+ }
2343
+ this.#clients.add(socket);
2344
+ this.#send(socket, { kind: "hello", protocol: PROTOCOL_VERSION });
2345
+ void this.#sendSnapshot(socket);
2346
+ socket.on("message", (data) => void this.#onMessage(socket, String(data)));
2347
+ socket.on("close", () => this.#clients.delete(socket));
2348
+ socket.on("error", () => this.#clients.delete(socket));
2349
+ }
2350
+ /**
2351
+ * Facts about this installation, for the Settings surface (issue #58).
2352
+ *
2353
+ * Computed once and cached: `findOwnPackage` walks the directory tree, and this is sent on every
2354
+ * connection. The version cannot change while the process runs, so recomputing it per connection
2355
+ * would be work done to produce a value already known to be identical.
2356
+ */
2357
+ #installFacts() {
2358
+ if (this.#install !== void 0) return this.#install;
2359
+ const own = findOwnPackage();
2360
+ this.#install = {
2361
+ // "unknown" rather than a substituted "0.0.0": a version that cannot be determined is a
2362
+ // packaging fault, and saying so is truthful. A plausible-looking default would be believed,
2363
+ // and every question asked afterwards would start from a false premise.
2364
+ version: own?.version ?? "unknown",
2365
+ sessionsRoot: this.#config.sessionsRoot,
2366
+ workspaceRoot: this.#config.workspaceRoot,
2367
+ agentRoot: this.#config.agentRoot,
2368
+ agentRootManaged: this.#config.agentRootManaged,
2369
+ protocol: PROTOCOL_VERSION
2370
+ };
2371
+ return this.#install;
2372
+ }
2373
+ async #sendSnapshot(socket) {
2374
+ let history = [];
2375
+ let historyTotal = 0;
2376
+ try {
2377
+ const discovered = await discover(this.#config);
2378
+ history = discovered.sessions;
2379
+ historyTotal = discovered.total;
2380
+ } catch (error) {
2381
+ this.#send(socket, { kind: "error", message: `discovery failed: ${error.message}` });
2382
+ }
2383
+ this.#send(socket, {
2384
+ kind: "snapshot",
2385
+ live: this.#supervisor.list(),
2386
+ history,
2387
+ historyTotal,
2388
+ home: this.#config.home,
2389
+ installationRoot: this.#config.installationRoot,
2390
+ install: this.#installFacts()
2391
+ });
2392
+ for (const session of this.#supervisor.list()) {
2393
+ if (session.state === "stopped" || session.state === "failed") continue;
2394
+ void this.#sendTranscript(socket, session.id);
2395
+ }
2396
+ }
2397
+ /** Fetch one session's conversation for one client, and report failure as a failure. */
2398
+ async #sendTranscript(socket, sessionId) {
2399
+ const result = await this.#supervisor.fetchTranscript(sessionId);
2400
+ if (!result.ok) {
2401
+ this.#send(socket, { kind: "transcriptError", sessionId, message: result.message });
2402
+ return;
2403
+ }
2404
+ this.#send(socket, { kind: "transcript", sessionId, messages: result.messages });
2405
+ }
2406
+ async #onMessage(socket, raw) {
2407
+ const control = parseControl(raw);
2408
+ if ("error" in control) {
2409
+ this.#send(socket, { kind: "error", message: `bad control: ${control.error}` });
2410
+ return;
2411
+ }
2412
+ if (!KNOWN_CONTROL_KINDS.has(control.kind)) {
2413
+ this.#send(socket, { kind: "error", message: `bad control: unknown kind '${control.kind}'` });
2414
+ return;
2415
+ }
2416
+ try {
2417
+ await this.#dispatch(control);
2418
+ } catch (error) {
2419
+ this.#send(socket, { kind: "error", message: error.message });
2420
+ }
2421
+ }
2422
+ /**
2423
+ * Route one control message.
2424
+ *
2425
+ * The switch is exhaustive over `Control`, so adding a command to the protocol without
2426
+ * handling it here is a compile error rather than a silent no-op.
2427
+ */
2428
+ async #dispatch(control) {
2429
+ switch (control.kind) {
2430
+ case "list": {
2431
+ const discovered = await discover(this.#config);
2432
+ this.#broadcast({ kind: "history", history: discovered.sessions, total: discovered.total });
2433
+ this.#broadcast({
2434
+ kind: "snapshot",
2435
+ live: this.#supervisor.list(),
2436
+ history: discovered.sessions,
2437
+ historyTotal: discovered.total,
2438
+ home: this.#config.home,
2439
+ installationRoot: this.#config.installationRoot
2440
+ });
2441
+ return;
2442
+ }
2443
+ case "start": {
2444
+ const result = this.#supervisor.start({
2445
+ ...control.cwd ? { cwd: control.cwd } : {},
2446
+ ...control.name ? { name: control.name } : {},
2447
+ ...control.mode ? { mode: control.mode } : {}
2448
+ });
2449
+ if (!result.ok) {
2450
+ this.#broadcast({ kind: "error", message: result.message });
2451
+ return;
2452
+ }
2453
+ this.#broadcast({ kind: "session", session: result.session });
2454
+ return;
2455
+ }
2456
+ case "resume": {
2457
+ const discovered = await discover(this.#config);
2458
+ const target = discovered.sessions.find((s) => s.path === control.path);
2459
+ if (!target) {
2460
+ this.#broadcast({ kind: "error", message: `no such session: ${control.path}` });
2461
+ return;
2462
+ }
2463
+ const result = this.#supervisor.resume(target, control.mode ? { mode: control.mode } : {});
2464
+ if (!result.ok) {
2465
+ this.#broadcast({ kind: "error", message: result.message });
2466
+ return;
2467
+ }
2468
+ this.#broadcast({ kind: "session", session: result.session });
2469
+ return;
2470
+ }
2471
+ case "prompt":
2472
+ await this.#supervisor.prompt(
2473
+ control.sessionId,
2474
+ control.message,
2475
+ ...control.behavior ? [control.behavior] : []
2476
+ );
2477
+ return;
2478
+ case "clearQueue": {
2479
+ await this.#supervisor.clearQueue(control.sessionId, control.andStop);
2480
+ return;
2481
+ }
2482
+ case "abort":
2483
+ await this.#supervisor.abort(control.sessionId);
2484
+ return;
2485
+ case "stop":
2486
+ await this.#supervisor.stop(control.sessionId);
2487
+ return;
2488
+ case "rename":
2489
+ await this.#supervisor.rename(control.sessionId, control.name);
2490
+ return;
2491
+ case "listModels": {
2492
+ const result = await this.#supervisor.listModels(control.sessionId);
2493
+ if (result.ok) this.#broadcast({ kind: "models", sessionId: control.sessionId, models: result.models });
2494
+ else this.#broadcast({ kind: "modelsError", sessionId: control.sessionId, message: result.message });
2495
+ return;
2496
+ }
2497
+ case "setModel": {
2498
+ await this.#supervisor.setModel(control.sessionId, control.provider, control.modelId);
2499
+ return;
2500
+ }
2501
+ case "listThinkingLevels": {
2502
+ const result = await this.#supervisor.listThinkingLevels(control.sessionId);
2503
+ if (result.ok) this.#broadcast({ kind: "thinkingLevels", sessionId: control.sessionId, levels: result.levels });
2504
+ else this.#broadcast({ kind: "thinkingLevelsError", sessionId: control.sessionId, message: result.message });
2505
+ return;
2506
+ }
2507
+ case "setThinkingLevel":
2508
+ await this.#supervisor.setThinkingLevel(control.sessionId, control.level);
2509
+ return;
2510
+ case "answerDialog":
2511
+ this.#supervisor.answerDialog(control.sessionId, control.dialogId, control.answer);
2512
+ return;
2513
+ // --- File browsing (issue #8, ADR-0007) ------------------------------------------------
2514
+ //
2515
+ // Resolved through the SUPERVISOR, not straight to `files.ts`, because the root is the
2516
+ // session's cwd and only the supervisor knows it. That keeps one answer to "which directory is
2517
+ // this session confined to" — and it means a request for an unknown session is refused as
2518
+ // such, rather than being resolved against some path the transport guessed.
2519
+ //
2520
+ // Everything below is read at the ORCHESTRATOR's UID, deliberately (ADR-0007). No sandbox is
2521
+ // involved: Safehouse confines agents, and this serves the person.
2522
+ case "listFiles": {
2523
+ const result = this.#supervisor.listFiles(control.sessionId, control.path, {
2524
+ ...control.includeHidden === void 0 ? {} : { includeHidden: control.includeHidden }
2525
+ });
2526
+ if (result.ok) {
2527
+ this.#broadcast({
2528
+ kind: "files",
2529
+ sessionId: control.sessionId,
2530
+ // The path as the CLIENT sent it, not the normalized one the orchestrator resolved.
2531
+ // The client keys its cache, its `loading` state and its expansion flag by the path it
2532
+ // requested, so echoing a different spelling would file the result under one key while
2533
+ // the node waited on another — leaving it in "Reading…" forever. Normalization stays
2534
+ // inside the orchestrator, where it is used for the filesystem work and for messages.
2535
+ path: control.path,
2536
+ entries: result.value.entries,
2537
+ truncated: result.value.truncated
2538
+ });
2539
+ } else {
2540
+ this.#broadcastFileError(control.sessionId, control.path, "list", result.error);
2541
+ }
2542
+ return;
2543
+ }
2544
+ case "readFile": {
2545
+ const result = this.#supervisor.readFile(control.sessionId, control.path);
2546
+ if (result.ok) {
2547
+ this.#broadcast({
2548
+ kind: "file",
2549
+ sessionId: control.sessionId,
2550
+ // As sent, for the same reason as `files`: one key for the client's bookkeeping.
2551
+ path: control.path,
2552
+ text: result.value.text,
2553
+ bytes: result.value.bytes
2554
+ });
2555
+ } else {
2556
+ this.#broadcastFileError(control.sessionId, control.path, "read", result.error);
2557
+ }
2558
+ return;
2559
+ }
2560
+ }
2561
+ }
2562
+ /**
2563
+ * Report a refused file request.
2564
+ *
2565
+ * `path` is echoed as the client SENT it, not as it resolved — the complaint is about what was
2566
+ * asked for, and a resolved path would describe something the user never typed.
2567
+ *
2568
+ * `request` distinguishes a failed listing from a failed read, so the client routes the error to the
2569
+ * right cache instead of guessing which request it answered.
2570
+ */
2571
+ #broadcastFileError(sessionId, path, request, error) {
2572
+ this.#broadcast({
2573
+ kind: "fileError",
2574
+ request,
2575
+ sessionId,
2576
+ path,
2577
+ reason: error.reason,
2578
+ ...error.code === void 0 ? {} : { code: error.code },
2579
+ message: error.message
2580
+ });
2581
+ }
2582
+ /** Fan out to every client. One slow client must not stall the others. */
2583
+ #broadcast(event) {
2584
+ for (const socket of this.#clients) this.#send(socket, event);
2585
+ }
2586
+ #send(socket, event) {
2587
+ if (socket.readyState !== socket.OPEN) return;
2588
+ const envelope = { dir: "orchestrator\u2192client", protocol: PROTOCOL_VERSION, payload: event };
2589
+ socket.send(JSON.stringify(envelope));
2590
+ }
2591
+ /**
2592
+ * Forward supervisor events to clients.
2593
+ *
2594
+ * Called by the entry point after construction, because it wires the supervisor's
2595
+ * `EventEmitter` to the socket. Kept out of the constructor so a test can build a
2596
+ * transport without an emitting supervisor.
2597
+ */
2598
+ attach() {
2599
+ this.#supervisor.on("update", (session) => {
2600
+ this.#broadcast({ kind: "session", session });
2601
+ });
2602
+ this.#supervisor.on("record", (sessionId, record) => {
2603
+ this.#broadcast({ kind: "record", sessionId, record });
2604
+ });
2605
+ this.#supervisor.on("transcript", (sessionId, messages) => {
2606
+ this.#broadcast({ kind: "transcript", sessionId, messages });
2607
+ });
2608
+ this.#supervisor.on("transcriptError", (sessionId, message) => {
2609
+ this.#broadcast({ kind: "transcriptError", sessionId, message });
2610
+ });
2611
+ this.#supervisor.on("queue", (sessionId, steering, followUp) => {
2612
+ this.#broadcast({ kind: "queue", sessionId, steering, followUp });
2613
+ });
2614
+ this.#supervisor.on(
2615
+ "queueCleared",
2616
+ (sessionId, steering, followUp) => {
2617
+ this.#broadcast({ kind: "queueCleared", sessionId, steering, followUp });
2618
+ }
2619
+ );
2620
+ this.#supervisor.on("error", (sessionId, message) => {
2621
+ this.#broadcast({ kind: "error", message, sessionId });
2622
+ });
2623
+ }
2624
+ async close() {
2625
+ for (const socket of this.#clients) socket.close(1001, "server shutting down");
2626
+ this.#clients.clear();
2627
+ const wss = this.#wss;
2628
+ const server = this.#server;
2629
+ this.#wss = void 0;
2630
+ this.#server = void 0;
2631
+ this.#address = void 0;
2632
+ if (wss) await new Promise((resolve8) => wss.close(() => resolve8()));
2633
+ if (server) await new Promise((resolve8) => server.close(() => resolve8()));
2634
+ }
2635
+ };
2636
+
2637
+ // packages/orchestrator/src/sandbox-guard.ts
2638
+ import { spawnSync } from "node:child_process";
2639
+ function probeSandbox(execPath = "/usr/bin/sandbox-exec") {
2640
+ const result = spawnSync(execPath, ["-p", "(version 1)(allow default)", "--", "/usr/bin/true"], {
2641
+ encoding: "utf8",
2642
+ timeout: 1e4
2643
+ });
2644
+ if (result.error) {
2645
+ return { canNest: false, detail: `cannot run ${execPath}: ${result.error.message}` };
2646
+ }
2647
+ if (result.status !== 0) {
2648
+ const stderr = (result.stderr ?? "").trim();
2649
+ return {
2650
+ canNest: false,
2651
+ detail: stderr.length > 0 ? stderr : `exited ${result.status ?? "on a signal"}`
2652
+ };
2653
+ }
2654
+ return { canNest: true, detail: "ok" };
2655
+ }
2656
+ var SandboxedEnvironmentError = class extends Error {
2657
+ constructor(message) {
2658
+ super(message);
2659
+ this.name = "SandboxedEnvironmentError";
2660
+ }
2661
+ };
2662
+ function assertNotSandboxed(execPath = "/usr/bin/sandbox-exec") {
2663
+ const probe = probeSandbox(execPath);
2664
+ if (probe.canNest) return probe;
2665
+ throw new SandboxedEnvironmentError(
2666
+ [
2667
+ "Pi Central cannot start: this process is inside a sandbox, and Seatbelt does not nest (C1).",
2668
+ "",
2669
+ ` probe: ${execPath} -p '(version 1)(allow default)' -- /usr/bin/true`,
2670
+ ` result: ${probe.detail}`,
2671
+ "",
2672
+ "Every session would be spawned through `safehouse`, which applies a policy with",
2673
+ "`sandbox-exec` -- and that fails with EPERM here, so every session would fail for a",
2674
+ "reason that looks like a launcher bug.",
2675
+ "",
2676
+ "Run Pi Central from a plain shell (not a sandboxed terminal, editor task, or agent",
2677
+ "harness). The orchestrator is intentionally unsandboxed at the user's UID (ADR-0002)."
2678
+ ].join("\n")
2679
+ );
2680
+ }
2681
+
2682
+ // packages/orchestrator/src/server.ts
2683
+ function versionLine() {
2684
+ const own = findOwnPackage();
2685
+ if (own === void 0) return `${PACKAGE_NAME} (version unknown: no package.json found)`;
2686
+ return `${own.name} ${own.version}`;
2687
+ }
2688
+ function printHelp() {
2689
+ console.log(`${versionLine()}
2690
+
2691
+ Usage: ${PACKAGE_NAME} [options]
2692
+
2693
+ Options:
2694
+ --port <n> Port to listen on (default 8787; 0 asks for any free one)
2695
+ --direct Start sessions WITHOUT the sandbox (feasibility only)
2696
+ --ui-dist <dir> Serve a built UI from this directory
2697
+ --no-ui Serve the socket only (use with a dev server)
2698
+ --allow-unsandboxed-host Skip the sandbox probe, for a host with no sandbox-exec (C10)
2699
+ --version, -v Print the version and exit
2700
+ --help, -h Print this and exit
2701
+
2702
+ Pi Central starts sessions with \`pi\` under Agent Safehouse. Run it from a PLAIN terminal:
2703
+ Seatbelt does not nest, so a sandboxed shell cannot start a sandboxed session (C1).`);
2704
+ }
2705
+ var DEFAULT_PORT = 8787;
2706
+ function parseArgs(argv) {
2707
+ const options = {
2708
+ port: DEFAULT_PORT,
2709
+ mode: "sandboxed",
2710
+ allowUnsandboxedHost: false,
2711
+ uiDir: resolveUiDistDir(),
2712
+ showVersion: false,
2713
+ showHelp: false
2714
+ };
2715
+ for (let i = 0; i < argv.length; i += 1) {
2716
+ const arg = argv[i];
2717
+ if (arg === "--version" || arg === "-v") {
2718
+ options.showVersion = true;
2719
+ } else if (arg === "--help" || arg === "-h") {
2720
+ options.showHelp = true;
2721
+ } else if (arg === "--port") {
2722
+ const value = Number(argv[++i]);
2723
+ if (Number.isInteger(value) && value >= 0 && value <= 65535) options.port = value;
2724
+ else {
2725
+ console.error(`--port expects a number between 0 and 65535, got ${String(argv[i])}`);
2726
+ process.exit(2);
2727
+ }
2728
+ } else if (arg === "--direct") {
2729
+ options.mode = "direct";
2730
+ } else if (arg === "--allow-unsandboxed-host") {
2731
+ options.allowUnsandboxedHost = true;
2732
+ } else if (arg === "--ui-dist") {
2733
+ const value = argv[++i];
2734
+ if (value === void 0 || value === "") {
2735
+ console.error("--ui-dist expects a directory containing a built UI");
2736
+ process.exit(2);
2737
+ }
2738
+ options.uiDir = value;
2739
+ } else if (arg === "--no-ui") {
2740
+ options.uiDir = null;
2741
+ }
2742
+ }
2743
+ return options;
2744
+ }
2745
+ async function main() {
2746
+ const options = parseArgs(process.argv.slice(2));
2747
+ if (options.showVersion) {
2748
+ console.log(versionLine());
2749
+ return 0;
2750
+ }
2751
+ if (options.showHelp) {
2752
+ printHelp();
2753
+ return 0;
2754
+ }
2755
+ if (!options.allowUnsandboxedHost) {
2756
+ try {
2757
+ assertNotSandboxed();
2758
+ } catch (error) {
2759
+ if (error instanceof SandboxedEnvironmentError) {
2760
+ console.error(error.message);
2761
+ return 2;
2762
+ }
2763
+ throw error;
2764
+ }
2765
+ }
2766
+ const config = defaultConfig();
2767
+ const supervisor = new Supervisor(config);
2768
+ const transport = new Transport(supervisor, config, { port: options.port, uiDir: options.uiDir });
2769
+ transport.attach();
2770
+ const address = await transport.listen();
2771
+ console.log(`PI_CENTRAL_URL=${address.url}`);
2772
+ console.log(`Pi Central listening on ${address.url}`);
2773
+ console.log(` sessions root: ${config.sessionsRoot}`);
2774
+ console.log(` default workspace: ${config.workspaceRoot}`);
2775
+ console.log(` agent root: ${config.agentRoot}${config.agentRootManaged ? " (managed)" : " (external)"}`);
2776
+ console.log(` isolation: ${options.mode}`);
2777
+ if (config.installationRoot === void 0) {
2778
+ console.log(
2779
+ `Self-hosting check: could not identify this installation's own directory, so sessions are
2780
+ not warned when they point at it. Point a session at a checkout of Pi Central and you may
2781
+ still hit the problems in docs/DOGFOODING.md.`
2782
+ );
2783
+ } else {
2784
+ console.log(`Self-hosting check: this installation is ${config.installationRoot}`);
2785
+ }
2786
+ console.log("");
2787
+ const httpUrl = address.url.replace(/^ws/, "http");
2788
+ if (options.uiDir === null) {
2789
+ console.log(`UI: not served (--no-ui). Use a dev server, or drop the flag to serve a build.`);
2790
+ } else if (hasBuiltUi(options.uiDir)) {
2791
+ console.log(`Open ${httpUrl} \u2014 the UI is served from ${options.uiDir}`);
2792
+ } else {
2793
+ console.log(
2794
+ `No built UI at ${options.uiDir}
2795
+ Build it with: npm run ui:build
2796
+ Or serve the socket only with: --no-ui (and run npm run ui:dev)`
2797
+ );
2798
+ }
2799
+ console.log("");
2800
+ if (options.uiDir !== null && hasBuiltUi(options.uiDir)) {
2801
+ console.log(`Start a session from the UI at ${httpUrl}. Press Ctrl-C to stop every session and exit.`);
2802
+ } else if (options.port !== DEFAULT_PORT) {
2803
+ console.log(`Non-default port. Start the dev UI with:
2804
+ PI_CENTRAL_WS=${address.url} npm run ui:dev`);
2805
+ console.log("");
2806
+ console.log("Start a session from the UI. Press Ctrl-C to stop every session and exit.");
2807
+ } else {
2808
+ console.log(`Start the dev UI in another terminal: npm run ui:dev`);
2809
+ console.log("");
2810
+ console.log("Start a session from the UI. Press Ctrl-C to stop every session and exit.");
2811
+ }
2812
+ let shuttingDown = false;
2813
+ const shutdown = async (signal) => {
2814
+ if (shuttingDown) return;
2815
+ shuttingDown = true;
2816
+ console.log(`
2817
+ ${signal} received; stopping sessions...`);
2818
+ await transport.close().catch(() => void 0);
2819
+ await supervisor.stopAll().catch(() => void 0);
2820
+ console.log("stopped.");
2821
+ process.exit(0);
2822
+ };
2823
+ process.on("SIGINT", () => void shutdown("SIGINT"));
2824
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
2825
+ return 0;
2826
+ }
2827
+ main().then(
2828
+ (code) => {
2829
+ if (code !== 0) process.exit(code);
2830
+ },
2831
+ (error) => {
2832
+ console.error(`Pi Central failed to start: ${error.message}`);
2833
+ process.exit(1);
2834
+ }
2835
+ );
2836
+ //# sourceMappingURL=pi-central.js.map