oh-my-agent-web 0.2.0 → 0.4.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 (250) hide show
  1. package/README.md +86 -447
  2. package/dist/server/approval/read-only.js +107 -0
  3. package/dist/server/approval/rules.js +85 -0
  4. package/dist/server/approval/scope.js +150 -0
  5. package/dist/server/brand.js +2 -0
  6. package/dist/server/cli-config.js +52 -0
  7. package/dist/server/cli-info.js +101 -0
  8. package/dist/server/cli.js +304 -0
  9. package/dist/server/engine/builtin.js +61 -0
  10. package/dist/server/engine/catalog.js +8 -0
  11. package/dist/server/engine/codex-approvals.js +45 -0
  12. package/dist/server/engine/codex-map.js +298 -0
  13. package/dist/server/engine/codex-sessions.js +159 -0
  14. package/dist/server/engine/codex-transport.js +49 -0
  15. package/dist/server/engine/codex.js +314 -0
  16. package/dist/server/engine/dsh-history.js +195 -0
  17. package/dist/server/engine/dsh-log.js +245 -0
  18. package/dist/server/engine/dsh-map.js +221 -0
  19. package/dist/server/engine/dsh-sessions.js +207 -0
  20. package/dist/server/engine/dsh-transport.js +37 -0
  21. package/dist/server/engine/dsh.js +284 -0
  22. package/dist/server/engine/fake.js +231 -0
  23. package/dist/server/engine/jsonrpc-stdio.js +241 -0
  24. package/dist/server/engine/model-registry-reload.js +115 -0
  25. package/dist/server/engine/pi-events.js +148 -0
  26. package/dist/server/engine/pi-rename.js +14 -0
  27. package/dist/server/engine/pi-sdk.js +343 -0
  28. package/dist/server/engine/pi-session-model.js +23 -0
  29. package/dist/server/engine/pi-thinking.js +34 -0
  30. package/dist/server/engine/pi-transcript.js +225 -0
  31. package/dist/server/engine/pi-version.js +11 -0
  32. package/dist/server/engine/port.js +1 -0
  33. package/dist/server/engine/registry.js +47 -0
  34. package/dist/server/engine/scratch.js +13 -0
  35. package/dist/server/engine/suggestion-guidance.js +9 -0
  36. package/dist/server/engine/switchable.js +22 -0
  37. package/dist/server/engine/wiring.js +107 -0
  38. package/dist/server/files/dir-routes.js +40 -0
  39. package/dist/server/files/file-search.js +63 -0
  40. package/dist/server/files/file-service.js +232 -0
  41. package/dist/server/files/files-routes.js +182 -0
  42. package/dist/server/files/git-info.js +186 -0
  43. package/dist/server/http/auth-routes.js +99 -0
  44. package/dist/server/http/auth.js +191 -0
  45. package/dist/server/http/aux-routes.js +24 -0
  46. package/dist/server/http/compress.js +75 -0
  47. package/dist/server/http/health-and-static.js +33 -0
  48. package/dist/server/http/live-routes.js +113 -0
  49. package/dist/server/http/login-page.js +200 -0
  50. package/dist/server/index.js +342 -520
  51. package/dist/server/markers/actions.js +262 -0
  52. package/dist/server/protocol-parse.js +201 -0
  53. package/dist/server/protocol-settings.js +1 -0
  54. package/dist/server/protocol.js +6 -0
  55. package/dist/server/providers/catalog-store.js +85 -0
  56. package/dist/server/providers/catalog-writes.js +197 -0
  57. package/dist/server/providers/catalog.js +77 -0
  58. package/dist/server/providers/enabled-models.js +118 -0
  59. package/dist/server/providers/import-models.js +69 -0
  60. package/dist/server/providers/import-routes.js +136 -0
  61. package/dist/server/providers/model-test.js +52 -0
  62. package/dist/server/providers/probe.js +64 -0
  63. package/dist/server/providers/provider-handlers.js +203 -0
  64. package/dist/server/providers/provider-store.js +327 -0
  65. package/dist/server/providers/request-headers.js +7 -0
  66. package/dist/server/providers/validate.js +241 -0
  67. package/dist/server/push/push-routes.js +114 -0
  68. package/dist/server/push/push.js +120 -0
  69. package/dist/server/session/approval-bridge.js +103 -0
  70. package/dist/server/session/attachment-resolve.js +21 -0
  71. package/dist/server/session/client-view.js +82 -0
  72. package/dist/server/session/conversation-create.js +65 -0
  73. package/dist/server/session/delete-session.js +84 -0
  74. package/dist/server/session/engine-switch.js +108 -0
  75. package/dist/server/session/lock.js +82 -0
  76. package/dist/server/session/model-controller.js +111 -0
  77. package/dist/server/session/notices.js +31 -0
  78. package/dist/server/session/pi-opener.js +123 -0
  79. package/dist/server/session/pi-refresh.js +51 -0
  80. package/dist/server/session/pi-sessions-provider.js +46 -0
  81. package/dist/server/session/pi-sessions.js +272 -0
  82. package/dist/server/session/refresh-actions.js +42 -0
  83. package/dist/server/session/rename-session.js +23 -0
  84. package/dist/server/session/runtime-services.js +28 -0
  85. package/dist/server/session/runtime-types.js +1 -0
  86. package/dist/server/session/runtime.js +358 -0
  87. package/dist/server/session/session-stats.js +88 -0
  88. package/dist/server/session/settings.js +367 -0
  89. package/dist/server/session/snapshot-for.js +7 -0
  90. package/dist/server/session/stats-refresher.js +50 -0
  91. package/dist/server/session/switch-conversation.js +86 -0
  92. package/dist/server/session/throttle.js +14 -0
  93. package/dist/server/session/turn-events.js +126 -0
  94. package/dist/server/session/turn-finish.js +79 -0
  95. package/dist/server/session/turn-markers.js +26 -0
  96. package/dist/server/session/turn-queue.js +111 -0
  97. package/dist/server/session/turn-send.js +88 -0
  98. package/dist/server/session/turn-watchdog.js +77 -0
  99. package/dist/server/sessions/session-routes.js +17 -0
  100. package/dist/server/skills/skill-routes.js +28 -0
  101. package/dist/server/skills/skill-store.js +134 -0
  102. package/dist/server/snapshot/fan-out.js +10 -0
  103. package/dist/server/snapshot/messages.js +75 -0
  104. package/dist/server/snapshot/sender.js +62 -0
  105. package/dist/server/snapshot/state.js +193 -0
  106. package/dist/server/terminal/terminal-routes.js +176 -0
  107. package/dist/server/terminal/terminal-session.js +140 -0
  108. package/dist/server/uploads/upload-routes.js +64 -0
  109. package/dist/server/uploads/upload-store.js +115 -0
  110. package/dist/server/util/data-dir.js +36 -0
  111. package/dist/server/util/env.js +140 -0
  112. package/dist/server/util/ids.js +28 -0
  113. package/dist/server/util/log-file.js +67 -0
  114. package/dist/server/util/log.js +52 -0
  115. package/dist/server/util/origin.js +75 -0
  116. package/dist/server/util/paths.js +60 -0
  117. package/dist/server/util/pricing-index.js +121 -0
  118. package/dist/server/util/pricing.js +214 -0
  119. package/dist/server/util/startup.js +34 -0
  120. package/dist/server/util/upstream-version.js +140 -0
  121. package/dist/server/util/usage.js +19 -0
  122. package/dist/server/util/version.js +50 -0
  123. package/dist/server/ws/handle-message.js +95 -0
  124. package/package.json +49 -63
  125. package/web/dist/apple-touch-icon-precomposed.png +0 -0
  126. package/web/dist/apple-touch-icon.png +0 -0
  127. package/web/dist/assets/abnfDiagram-WEYOIA3C-NLp09iEC.js +1 -0
  128. package/web/dist/assets/arc-DviL9o3c.js +1 -0
  129. package/web/dist/assets/architecture-WOLXFQ4H-fjsM_Sy3.js +1 -0
  130. package/web/dist/assets/architectureDiagram-ZHVYKT53-Cn3B4TE8.js +36 -0
  131. package/web/dist/assets/array-BifhSqXX.js +1 -0
  132. package/web/dist/assets/blockDiagram-RJAE7QA3-CIuvxWvi.js +140 -0
  133. package/web/dist/assets/c4Diagram-XDPPPE54-DvdKmEpF.js +38 -0
  134. package/web/dist/assets/channel-C3AQ6nAs.js +1 -0
  135. package/web/dist/assets/chunk-2E7WHVMD-HNNASGCS.js +88 -0
  136. package/web/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  137. package/web/dist/assets/chunk-4EMNKR2T-Cg-eRwBE.js +1 -0
  138. package/web/dist/assets/chunk-55XCU76K-BweiisZL.js +1 -0
  139. package/web/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  140. package/web/dist/assets/chunk-7S6XSTFO-BCzIiP-6.js +2 -0
  141. package/web/dist/assets/chunk-AQDTSKZB-Dl0NRFuU.js +72 -0
  142. package/web/dist/assets/chunk-DSNLSHJZ-zhHCjjN9.js +129 -0
  143. package/web/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
  144. package/web/dist/assets/chunk-HAGFOTED-BDQqU2EH.js +62 -0
  145. package/web/dist/assets/chunk-HNE4Q36P-B-qRzMjX.js +1 -0
  146. package/web/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
  147. package/web/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  148. package/web/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
  149. package/web/dist/assets/chunk-NQOKRVIF-CNcvhMn_.js +213 -0
  150. package/web/dist/assets/chunk-UHW7PFBP-Dwr8LUzr.js +1 -0
  151. package/web/dist/assets/chunk-VQ5W62QI-O8sN6jJu.js +1 -0
  152. package/web/dist/assets/chunk-WQQL4CDK-B6-59Zg2.js +10 -0
  153. package/web/dist/assets/chunk-XXDRQBXY-DvSbeUlZ.js +1 -0
  154. package/web/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  155. package/web/dist/assets/chunk-YZO67HFG-BXpKYKsw.js +1 -0
  156. package/web/dist/assets/chunk-ZAUX2LTX-B1Z5VSkz.js +1 -0
  157. package/web/dist/assets/chunk-ZCHMQSOR-h_5mBLIA.js +2 -0
  158. package/web/dist/assets/classDiagram-v2-4MYWR3UA-Dr6JnaaV.js +217 -0
  159. package/web/dist/assets/cose-bilkent-JH36ORCC-36ShkR7R.js +1 -0
  160. package/web/dist/assets/cynefin-EF2NZ3EQ-Dg6bI7pC.js +1 -0
  161. package/web/dist/assets/cynefinDiagram-WF24UDRP-B0YHpBlR.js +62 -0
  162. package/web/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  163. package/web/dist/assets/dagre-NK4GIZVX-Cx3mn7YS.js +4 -0
  164. package/web/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  165. package/web/dist/assets/diagram-CBK3MWTC-l8aZG_Bm.js +24 -0
  166. package/web/dist/assets/diagram-I5XIIV2D-Bd5f14_h.js +24 -0
  167. package/web/dist/assets/diagram-ODIL2UCW-hBsxCYUj.js +30 -0
  168. package/web/dist/assets/diagram-PFBEJUXE-BgpIBUYl.js +3 -0
  169. package/web/dist/assets/diagram-RJA3I4VV-CDU1gUVD.js +41 -0
  170. package/web/dist/assets/diagram-TYUNFSNR-Iuxh7Tqf.js +200 -0
  171. package/web/dist/assets/dist-C86cKTuE.js +1 -0
  172. package/web/dist/assets/ebnfDiagram-V4P3TTPT-Cms0HFKh.js +1 -0
  173. package/web/dist/assets/elk-QONTBCTQ-B7yEy0PQ.js +27 -0
  174. package/web/dist/assets/erDiagram-KYQIGQAA-CL48j4aU.js +99 -0
  175. package/web/dist/assets/eventmodeling-K75KTNOO-CFvkaejh.js +1 -0
  176. package/web/dist/assets/flowDiagram-IUGLLWBU-6UyKXpqG.js +1 -0
  177. package/web/dist/assets/ganttDiagram-R7GPNTJY-vdMr-0ZV.js +292 -0
  178. package/web/dist/assets/gitGraph-VSP46ZUC-2z85Wuwo.js +1 -0
  179. package/web/dist/assets/gitGraphDiagram-PDYL7XHY-BY4GPiqC.js +106 -0
  180. package/web/dist/assets/graphlib-DS17s2tU.js +1 -0
  181. package/web/dist/assets/index-Bjn56a2i.js +76 -0
  182. package/web/dist/assets/index-Drp6e5iU.css +1 -0
  183. package/web/dist/assets/info-OHQRW6UA-ECzkYTx-.js +1 -0
  184. package/web/dist/assets/infoDiagram-MVDRKAX5-DDk5EAeb.js +2 -0
  185. package/web/dist/assets/init-C-OQMol4.js +1 -0
  186. package/web/dist/assets/ishikawaDiagram-HPEHV2GE-9yPH4J94.js +70 -0
  187. package/web/dist/assets/journeyDiagram-BOSHW7O7-BPTIap3l.js +139 -0
  188. package/web/dist/assets/kanban-definition-FNSA4HC5-BbADHrA8.js +89 -0
  189. package/web/dist/assets/katex-B_JcIwqj.js +257 -0
  190. package/web/dist/assets/line-CHV9_4lL.js +1 -0
  191. package/web/dist/assets/linear-C-eZsBTv.js +1 -0
  192. package/web/dist/assets/mermaid-parser.core-BiAhX1l1.js +7 -0
  193. package/web/dist/assets/mermaid.core-BxP_A_zj.js +44 -0
  194. package/web/dist/assets/mindmap-definition-6OY57UG3-BMWlit95.js +98 -0
  195. package/web/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  196. package/web/dist/assets/packet-JDAUHWVQ-FVApuKOp.js +1 -0
  197. package/web/dist/assets/path-fybaL0A-.js +1 -0
  198. package/web/dist/assets/pegDiagram-ECIUJNBX-WjWJvDHM.js +1 -0
  199. package/web/dist/assets/pie-XZMESJXO-BGCZnvhp.js +1 -0
  200. package/web/dist/assets/pieDiagram-GNMPY74T-DJpAC_JC.js +39 -0
  201. package/web/dist/assets/quadrantDiagram-D57K4MC3-hxx5c7oq.js +7 -0
  202. package/web/dist/assets/radar-ABXABTNO-BU9Shc-O.js +1 -0
  203. package/web/dist/assets/railroad-I3PHUGI6-pWj8d-G4.js +1 -0
  204. package/web/dist/assets/railroad-abnf-LNEFI6M7-D7HZBTAj.js +1 -0
  205. package/web/dist/assets/railroad-ebnf-DWYD2UWJ-CKiXJSuM.js +1 -0
  206. package/web/dist/assets/railroad-peg-JR7BVNR7-DB3VJHgR.js +1 -0
  207. package/web/dist/assets/railroadDiagram-Y72NRBL7-Cev7Y65k.js +1 -0
  208. package/web/dist/assets/requirementDiagram-EQYSW5RA-CqbwMLD8.js +84 -0
  209. package/web/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  210. package/web/dist/assets/sankeyDiagram-SY24JHO5-V2cyVi8q.js +40 -0
  211. package/web/dist/assets/sequenceDiagram-BLEHNHQU-By6NJOPM.js +169 -0
  212. package/web/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  213. package/web/dist/assets/src-Da91fG3g.js +1 -0
  214. package/web/dist/assets/stateDiagram-v2-A6IESKWZ-BnPGX5UI.js +291 -0
  215. package/web/dist/assets/swimlanes-B3ENRKFQ-C58BpIpm.js +1 -0
  216. package/web/dist/assets/swimlanesDiagram-AYQRV4AK-RaivxSsc.js +8 -0
  217. package/web/dist/assets/timeline-definition-JXYKGLGB-Hgo1DhnS.js +120 -0
  218. package/web/dist/assets/treeView-D4JQ5SDB-DF0dfjqB.js +1 -0
  219. package/web/dist/assets/treemap-SAJKECNS-CSSM5FqB.js +1 -0
  220. package/web/dist/assets/usecaseDiagram-WPOLBNNX-BkBBD1NY.js +328 -0
  221. package/web/dist/assets/vennDiagram-Q3Y3IDCW-Bm28keX_.js +34 -0
  222. package/web/dist/assets/wardley-7MLQ67FV-By7KgEQX.js +1 -0
  223. package/web/dist/assets/wardleyDiagram-IVAIDKWA-oVtIrnGa.js +78 -0
  224. package/web/dist/assets/xychartDiagram-4URNHDU4-aI8FH4g2.js +7 -0
  225. package/web/dist/favicon-32.png +0 -0
  226. package/web/dist/favicon.ico +0 -0
  227. package/web/dist/favicon.svg +20 -8
  228. package/web/dist/icons/apple-touch-icon-180.png +0 -0
  229. package/web/dist/icons/icon-192.png +0 -0
  230. package/web/dist/icons/icon-512.png +0 -0
  231. package/web/dist/icons/icon-maskable-512.png +0 -0
  232. package/web/dist/index.html +21 -27
  233. package/web/dist/manifest.webmanifest +17 -0
  234. package/web/dist/maskable.svg +25 -0
  235. package/web/dist/sw.js +40 -0
  236. package/bin/oh-my-agent-web-ps.mjs +0 -203
  237. package/bin/oh-my-agent-web-ws.mjs +0 -593
  238. package/bin/oh-my-agent-web.mjs +0 -321
  239. package/bin/update-check.mjs +0 -142
  240. package/dist/server/codex-client.js +0 -310
  241. package/dist/server/codex-supervisor.js +0 -446
  242. package/dist/server/fs-service.js +0 -144
  243. package/dist/server/request-inspector.js +0 -458
  244. package/dist/shared/protocol.js +0 -112
  245. package/scripts/manage-server.mjs +0 -155
  246. package/scripts/omaw.mjs +0 -74
  247. package/shared/protocol.ts +0 -228
  248. package/web/dist/assets/index-B8-VzDf2.css +0 -1
  249. package/web/dist/assets/index-DeC2eiiq.js +0 -42
  250. package/web/dist/assets/markdown-CYXe6JZx.js +0 -55
@@ -0,0 +1,62 @@
1
+ export const DEFAULT_WINDOW_MS = 60;
2
+ export function createSender(options) {
3
+ const windowMs = options.windowMs ?? DEFAULT_WINDOW_MS;
4
+ const stats = {
5
+ sentSnapshots: 0,
6
+ coalescedSnapshots: 0,
7
+ skippedSnapshots: 0,
8
+ sentEvents: 0,
9
+ sentReplies: 0,
10
+ };
11
+ let pending = null;
12
+ let lastSentAt = Number.NEGATIVE_INFINITY;
13
+ let busy = false;
14
+ const sendNow = () => {
15
+ if (!pending)
16
+ return false;
17
+ options.send({ kind: "snapshot", snapshot: pending });
18
+ pending = null;
19
+ stats.sentSnapshots += 1;
20
+ return true;
21
+ };
22
+ return {
23
+ pushSnapshot(snapshot) {
24
+ if (pending)
25
+ stats.coalescedSnapshots += 1;
26
+ pending = snapshot;
27
+ },
28
+ pushEvent(event) {
29
+ options.send({ kind: "event", event });
30
+ stats.sentEvents += 1;
31
+ },
32
+ pushReply(reply) {
33
+ options.send({ kind: "reply", reply });
34
+ stats.sentReplies += 1;
35
+ },
36
+ tick(now) {
37
+ if (!pending)
38
+ return;
39
+ if (busy) {
40
+ stats.skippedSnapshots += 1;
41
+ return;
42
+ }
43
+ if (now - lastSentAt < windowMs)
44
+ return;
45
+ if (sendNow())
46
+ lastSentAt = now;
47
+ },
48
+ setBusy(next) {
49
+ busy = next;
50
+ },
51
+ flush(now) {
52
+ if (sendNow())
53
+ lastSentAt = now;
54
+ },
55
+ stats() {
56
+ return { ...stats };
57
+ },
58
+ hasPendingSnapshot() {
59
+ return pending !== null;
60
+ },
61
+ };
62
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * 服务端状态与快照构造(ADR-0012)。
3
+ *
4
+ * 服务端持有唯一事实源;`buildSnapshot` 是纯函数:同一份 state 永远得到同一份快照,
5
+ * 且**丢任意中间帧后,下一帧仍然正确** —— 这正是「快照可丢」能成立的原因。
6
+ */
7
+ import { SNAPSHOT_MESSAGE_LIMIT, PROTOCOL_VERSION, } from "../protocol.js";
8
+ import { isOutdated } from "../util/upstream-version.js";
9
+ export function emptyRunning() {
10
+ return { status: "idle", conversationId: null, turnStartedAt: null, streamingText: "", activeTool: null };
11
+ }
12
+ export function createServerState(workspace, engine = "pi") {
13
+ return {
14
+ workspace,
15
+ engine,
16
+ engines: [],
17
+ model: null,
18
+ // ADR-0034:默认全部放行(提示仍然会出现在快照/悬浮牌上,用户可以随时切回「询问」)
19
+ approvalMode: "allow",
20
+ engineCapabilities: { approvals: true, tools: true, modelSwitch: true, thinkingLevel: true, resume: true },
21
+ providers: [],
22
+ thinkingLevel: null,
23
+ thinkingLevels: [],
24
+ sessionStats: null,
25
+ models: [],
26
+ piProjects: [],
27
+ piProjectsVersion: 0,
28
+ settings: {
29
+ quickPhrases: [],
30
+ quickPhrasesEnabled: true,
31
+ templates: [],
32
+ projectPrefs: { order: [], pinned: [], aliases: {} },
33
+ sessionPrefs: { pinned: [], archived: [] },
34
+ defaultModels: {},
35
+ thinkCount: 5,
36
+ thinkTimeoutSec: 20,
37
+ },
38
+ actions: [],
39
+ conversations: [],
40
+ activeId: null,
41
+ messages: {},
42
+ running: emptyRunning(),
43
+ queued: [],
44
+ enabledModels: [],
45
+ statsByConversation: {},
46
+ modelByConversation: {},
47
+ thinkingByConversation: {},
48
+ filesRootByConversation: {},
49
+ runningByConversation: {},
50
+ pendingApproval: null,
51
+ versions: { web: "unknown", engine: null, latest: null },
52
+ notices: [],
53
+ terminal: null,
54
+ files: null,
55
+ };
56
+ }
57
+ /** 换一份 pi 会话清单:顺手把版本号 +1(快照靠它决定「这一帧要不要带清单」) */
58
+ export function setPiProjects(state, projects) {
59
+ state.piProjects = projects;
60
+ state.piProjectsVersion += 1;
61
+ }
62
+ /** 会话元数据刷新(内存态):updatedAt / messageCount 跟着消息走 */
63
+ export function touchConversationIn(state, conversationId, now, patch = {}) {
64
+ state.conversations = state.conversations.map((conversation) => conversation.id === conversationId
65
+ ? {
66
+ ...conversation,
67
+ ...patch,
68
+ updatedAt: now,
69
+ messageCount: state.messages[conversationId]?.length ?? conversation.messageCount,
70
+ }
71
+ : conversation);
72
+ }
73
+ export function createConversation(input) {
74
+ return {
75
+ id: input.id,
76
+ title: input.title,
77
+ createdAt: input.now,
78
+ updatedAt: input.now,
79
+ workspace: input.workspace,
80
+ messageCount: 0,
81
+ };
82
+ }
83
+ /** 只取最近 limit 条(ADR-0012:N=100),更早的靠 hasMoreBefore 提示按需拉 */
84
+ /**
85
+ * @param activeIdOverride 该客户端自己选中的会话(ADR-0033):
86
+ * 多标签 / 多设备各自看各自的会话,不再跟着服务端那个全局 activeId 跑。
87
+ */
88
+ /**
89
+ * 这个客户端看到的「运行状态」。
90
+ *
91
+ * - 没传 activeIdOverride(老路径):直接用全局 running;
92
+ * - 传了且正好是全局那个会话:也用全局(它带着流式正文与工具信息);
93
+ * - 传了但是别的会话:从 runningByConversation 里取它自己的状态,
94
+ * 流式正文/工具不属于这条会话,留空。
95
+ */
96
+ function runningForClient(state, activeId, override) {
97
+ if (override === undefined || override === state.running.conversationId) {
98
+ return { ...state.running, activeTool: state.running.activeTool ? { ...state.running.activeTool } : null };
99
+ }
100
+ const own = activeId ? state.runningByConversation[activeId] : undefined;
101
+ return {
102
+ status: own?.status ?? "idle",
103
+ conversationId: activeId,
104
+ turnStartedAt: own?.turnStartedAt ?? null,
105
+ streamingText: "",
106
+ activeTool: null,
107
+ };
108
+ }
109
+ /** 会话级缓存里取该会话的值;没有记录就回落到全局那份 */
110
+ function pickPerConversation(cache, activeId, fallback) {
111
+ if (!activeId)
112
+ return fallback;
113
+ return Object.hasOwn(cache, activeId) ? cache[activeId] : fallback;
114
+ }
115
+ export function buildSnapshot(state, limit = SNAPSHOT_MESSAGE_LIMIT, activeIdOverride, options = {}) {
116
+ const activeId = activeIdOverride === undefined ? state.activeId : activeIdOverride;
117
+ const all = activeId ? (state.messages[activeId] ?? []) : [];
118
+ const messages = all.length > limit ? all.slice(all.length - limit) : all;
119
+ return {
120
+ protocolVersion: PROTOCOL_VERSION,
121
+ engine: state.engine,
122
+ engines: state.engines.map((option) => ({ ...option, capabilities: { ...option.capabilities } })),
123
+ model: pickPerConversation(state.modelByConversation, activeId, state.model),
124
+ approvalMode: state.approvalMode,
125
+ engineCapabilities: { ...state.engineCapabilities },
126
+ providers: state.providers.map((provider) => ({
127
+ ...provider,
128
+ models: provider.models.map((model) => ({ ...model })),
129
+ })),
130
+ thinkingLevel: pickPerConversation(state.thinkingByConversation, activeId, state.thinkingLevel),
131
+ thinkingLevels: [...state.thinkingLevels],
132
+ sessionStats: state.sessionStats ? { ...state.sessionStats } : null,
133
+ models: state.models.map((model) => ({ ...model })),
134
+ actions: state.actions.map((action) => ({ ...action })),
135
+ settings: {
136
+ quickPhrases: [...state.settings.quickPhrases],
137
+ quickPhrasesEnabled: state.settings.quickPhrasesEnabled,
138
+ templates: state.settings.templates.map((template) => ({ ...template })),
139
+ projectPrefs: {
140
+ order: [...state.settings.projectPrefs.order],
141
+ pinned: [...state.settings.projectPrefs.pinned],
142
+ aliases: { ...state.settings.projectPrefs.aliases },
143
+ },
144
+ defaultModels: { ...state.settings.defaultModels },
145
+ sessionPrefs: {
146
+ pinned: [...state.settings.sessionPrefs.pinned],
147
+ archived: [...state.settings.sessionPrefs.archived],
148
+ },
149
+ thinkCount: state.settings.thinkCount,
150
+ thinkTimeoutSec: state.settings.thinkTimeoutSec,
151
+ },
152
+ ...(options.includeProjects === false
153
+ ? {}
154
+ : {
155
+ piProjects: state.piProjects.map((project) => ({
156
+ ...project,
157
+ sessions: project.sessions.map((session) => ({ ...session })),
158
+ })),
159
+ }),
160
+ conversations: state.conversations.map((conversation) => ({ ...conversation })),
161
+ active: {
162
+ conversationId: activeId,
163
+ messages: messages.map((message) => ({ ...message, blocks: message.blocks.map((block) => ({ ...block })) })),
164
+ hasMoreBefore: all.length > messages.length,
165
+ },
166
+ // running 也要按客户端的会话算:否则别人在跑时,你在看的会话既不出「引导/排队」,
167
+ // 也看不到 agent 在工作(但状态栏又显示「工作中」)—— A 方案落地后的连带问题
168
+ running: runningForClient(state, activeId, activeIdOverride),
169
+ queued: state.queued.map((item) => ({ ...item })),
170
+ enabledModels: [...state.enabledModels],
171
+ runningByConversation: { ...state.runningByConversation },
172
+ pendingApproval: state.pendingApproval ? { ...state.pendingApproval } : null,
173
+ // 标不标红在这里算:快照是纯函数,界面拿到的就是一个结论,不用自己比版本号
174
+ versions: {
175
+ web: state.versions.web,
176
+ engine: state.versions.engine,
177
+ latest: state.versions.latest,
178
+ outdated: isOutdated(state.versions.latest, state.versions.web),
179
+ },
180
+ terminal: state.terminal ? { ...state.terminal } : null,
181
+ files: (() => {
182
+ const root = activeId ? state.filesRootByConversation[activeId] : undefined;
183
+ if (!root)
184
+ return state.files ? { ...state.files } : null;
185
+ return { root, treeVersion: state.files?.treeVersion ?? 0 };
186
+ })(),
187
+ notices: state.notices.map((notice) => ({ ...notice })),
188
+ };
189
+ }
190
+ /** 快照体积(字节)—— ADR-0012 的体积护栏用它判断是否需要减小 N */
191
+ export function snapshotBytes(snapshot) {
192
+ return Buffer.byteLength(JSON.stringify(snapshot), "utf8");
193
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * 终端的 HTTP + SSE 通道(ADR-0027)。
3
+ *
4
+ * 为什么不用 WS:WS 是「协议单源」(ADR-0004),加 4 条终端消息要动 protocol / 白名单 / 分发,
5
+ * 收益不如成本。终端本质是「一条向前读的字节流 + 几条控制请求」,SSE 正好:
6
+ * POST /api/terminal/start 起 PTY(cwd = 当前会话工作区)
7
+ * GET /api/terminal/stream SSE:输出流(text/event-stream)
8
+ * POST /api/terminal/input 键盘输入
9
+ * POST /api/terminal/resize 窗口尺寸
10
+ * POST /api/terminal/stop 关掉 PTY
11
+ *
12
+ * 同时只允许 1 个 PTY(ADR-0027 §2)。
13
+ */
14
+ import { spawn } from "node:child_process";
15
+ import { realpathSync, statSync } from "node:fs";
16
+ import { startTerminal } from "./terminal-session.js";
17
+ export function registerTerminalRoutes(app, options) {
18
+ /**
19
+ * 多终端注册表(ADR-0027 增补):每个终端一个 id,互不影响。
20
+ * 之前是「全局单例」,所以开第二个终端会把第一个杀掉(用户反馈的"切换终端就退出")。
21
+ */
22
+ const terminals = new Map();
23
+ /** 被重启/关闭过的 id:迟到的 onExit 一律忽略(否则会污染新终端) */
24
+ const discarded = new Set();
25
+ const broadcastFor = (id, chunk) => {
26
+ const entry = terminals.get(id);
27
+ if (!entry)
28
+ return;
29
+ for (const listener of entry.listeners)
30
+ listener(chunk);
31
+ };
32
+ app.post("/api/terminal/start", async (request, response) => {
33
+ const workspace = options.workspaceOf();
34
+ if (!workspace) {
35
+ response
36
+ .status(409)
37
+ .json({ error: { code: "terminal.no_workspace", message: "还没有打开会话(不知道在哪个目录起终端)" } });
38
+ return;
39
+ }
40
+ const body = request.body;
41
+ const id = typeof body?.id === "string" && body.id !== "" ? body.id : `term-${Date.now().toString(36)}`;
42
+ // 同一个 id 重复 start = 重启(例如刷新页面),先收掉旧的
43
+ terminals.get(id)?.session.kill();
44
+ terminals.delete(id);
45
+ // 旧 PTY 的 onExit 是异步的:它可能在新终端注册之后才到达。
46
+ // 用「已废弃的 id 集合」把这类迟到的退出事件丢掉,否则新终端里会冒出「进程已退出」。
47
+ discarded.add(id);
48
+ // 指定了 cwd(从项目菜单「在终端中打开」进来):必须存在且是目录,且以它自身为根
49
+ let root = workspace;
50
+ let cwd = workspace;
51
+ if (typeof body?.cwd === "string" && body.cwd.startsWith("/")) {
52
+ try {
53
+ const real = realpathSync(body.cwd);
54
+ if (statSync(real).isDirectory()) {
55
+ root = real;
56
+ cwd = real;
57
+ }
58
+ }
59
+ catch {
60
+ // 路径不可用 → 回落到当前工作区
61
+ }
62
+ }
63
+ const result = await startTerminal({
64
+ workspaceRoot: root,
65
+ cwd,
66
+ cols: body?.cols,
67
+ rows: body?.rows,
68
+ handlers: {
69
+ onData: (chunk) => broadcastFor(id, chunk),
70
+ onExit: (exitCode) => {
71
+ if (discarded.delete(id))
72
+ return; // 这条退出属于被替换掉的旧会话
73
+ broadcastFor(id, `\r\n[进程已退出,code=${exitCode}]\r\n`);
74
+ terminals.delete(id);
75
+ },
76
+ },
77
+ });
78
+ if (!result.ok) {
79
+ response.status(503).json({ error: { code: "terminal.start_failed", message: result.error } });
80
+ return;
81
+ }
82
+ terminals.set(id, { session: result.terminal, listeners: new Set() });
83
+ response.json({ ok: true, id, cols: result.terminal.cols(), rows: result.terminal.rows() });
84
+ });
85
+ app.get("/api/terminal/stream", (request, response) => {
86
+ response.writeHead(200, {
87
+ "content-type": "text/event-stream",
88
+ "cache-control": "no-cache",
89
+ connection: "keep-alive",
90
+ });
91
+ response.write(": connected\n\n");
92
+ const id = typeof request.query.id === "string" ? request.query.id : ([...terminals.keys()].at(-1) ?? "");
93
+ const entry = terminals.get(id);
94
+ if (!entry) {
95
+ response
96
+ .writeHead(404, { "content-type": "application/json" })
97
+ .end(JSON.stringify({ error: { code: "terminal.not_found", message: "没有这个终端" } }));
98
+ return;
99
+ }
100
+ const listener = (chunk) => {
101
+ // SSE 帧里的换行必须转义,否则浏览器会把一帧拆成两条
102
+ response.write(`data: ${JSON.stringify(chunk)}\n\n`);
103
+ };
104
+ entry.listeners.add(listener);
105
+ request.on("close", () => entry.listeners.delete(listener));
106
+ });
107
+ app.post("/api/terminal/input", (request, response) => {
108
+ const body = request.body;
109
+ const entry = terminals.get(typeof body?.id === "string" ? body.id : "");
110
+ if (typeof body?.data === "string" && entry)
111
+ entry.session.write(body.data);
112
+ response.json({ ok: true });
113
+ });
114
+ app.post("/api/terminal/resize", (request, response) => {
115
+ const body = request.body;
116
+ const entry = terminals.get(typeof body?.id === "string" ? body.id : "");
117
+ if (entry && typeof body?.cols === "number" && typeof body?.rows === "number")
118
+ entry.session.resize(body.cols, body.rows);
119
+ response.json({ ok: true });
120
+ });
121
+ app.post("/api/terminal/stop", (request, response) => {
122
+ const body = request.body;
123
+ const id = typeof body?.id === "string" ? body.id : "";
124
+ if (id !== "") {
125
+ terminals.get(id)?.session.kill();
126
+ terminals.delete(id);
127
+ discarded.add(id);
128
+ }
129
+ response.json({ ok: true });
130
+ });
131
+ // 进程退出时统一收拾(不留孤儿 shell)
132
+ process.on("exit", () => {
133
+ for (const entry of terminals.values())
134
+ entry.session.kill();
135
+ });
136
+ }
137
+ /**
138
+ * 「在文件中打开」/「在终端中打开」的支撑(ADR-0027 增补)。
139
+ *
140
+ * 安全边界(刻意保守):
141
+ * - **只接受绝对路径**,且 realpath 后必须存在、必须是目录;
142
+ * - 「在文件中打开」用 `open -R`(**只定位、不执行**):直接 `open <path>` 遇到 .app/.command
143
+ * 会**执行**它,这是必须避开的坑;
144
+ * - 不读目录内容、不返回任何文件信息,只回 ok。
145
+ */
146
+ export function registerProjectActions(app, _options) {
147
+ app.post("/api/projects/reveal", async (request, response) => {
148
+ const cwd = typeof request.body?.cwd === "string"
149
+ ? String(request.body.cwd)
150
+ : "";
151
+ const safe = await safeDirectory(cwd);
152
+ if (!safe) {
153
+ response.status(400).json({ error: { code: "project.bad_path", message: "不是可用的目录路径" } });
154
+ return;
155
+ }
156
+ if (process.platform !== "darwin") {
157
+ response.status(501).json({ error: { code: "project.unsupported", message: "当前平台暂不支持" } });
158
+ return;
159
+ }
160
+ // -R = 在 Finder 里定位,不执行
161
+ spawn("open", ["-R", safe], { detached: true, stdio: "ignore" }).unref();
162
+ response.json({ ok: true });
163
+ });
164
+ }
165
+ /** 纯函数式的校验:绝对路径 + 存在 + 是目录 */
166
+ async function safeDirectory(cwd) {
167
+ if (!cwd.startsWith("/"))
168
+ return null;
169
+ try {
170
+ const real = realpathSync(cwd);
171
+ return statSync(real).isDirectory() ? real : null;
172
+ }
173
+ catch {
174
+ return null;
175
+ }
176
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * 终端会话(ADR-0027):一个 PTY + 输出节流。
3
+ *
4
+ * 为什么必须是 PTY(node-pty)而不是 child_process.spawn:
5
+ * vim / top / Ctrl-C / 颜色 / 行编辑,全都依赖"对面是个终端"。用管道跑出来的东西
6
+ * 在真实使用里几乎不可用(vim 会直接报错),所以这条依赖是必要的。
7
+ *
8
+ * 安全与稳定的三条硬约束:
9
+ * 1. cwd 由调用方给定,**只允许在工作区内**(由调用方用 resolveInsideRoot 保证);
10
+ * 2. 环境变量最小化,不把 AGENT_WEB_* 之类内部变量透传给 shell;
11
+ * 3. 输出**节流合并**(30ms 一批、单批 ≤64KB),否则 `yes` 一条命令就能把进程打爆。
12
+ */
13
+ import { resolveInsideRoot } from "../util/paths.js";
14
+ import { realpathSync } from "node:fs";
15
+ export const OUTPUT_FLUSH_MS = 30;
16
+ export const MAX_CHUNK_BYTES = 64 * 1024;
17
+ export const MIN_COLS = 20;
18
+ export const MAX_COLS = 400;
19
+ export const MIN_ROWS = 5;
20
+ export const MAX_ROWS = 200;
21
+ /** 纯函数:把前端报上来的尺寸夹到合法区间(防止 0 列导致 PTY 崩) */
22
+ export function clampSize(cols, rows) {
23
+ const safeCols = Number.isFinite(cols) ? Math.floor(cols) : 80;
24
+ const safeRows = Number.isFinite(rows) ? Math.floor(rows) : 24;
25
+ return {
26
+ cols: Math.min(Math.max(safeCols, MIN_COLS), MAX_COLS),
27
+ rows: Math.min(Math.max(safeRows, MIN_ROWS), MAX_ROWS),
28
+ };
29
+ }
30
+ /** 纯函数:给 shell 的最小环境(不透传内部变量,不继承可能的密钥) */
31
+ export function shellEnv(base = process.env) {
32
+ const keep = ["HOME", "PATH", "SHELL", "USER", "LOGNAME", "TMPDIR", "LANG", "LC_ALL"]; // TERM 必须在下面强制
33
+ const env = { TERM: "xterm-256color" };
34
+ for (const key of keep) {
35
+ const value = base[key];
36
+ if (typeof value === "string" && value !== "")
37
+ env[key] = value;
38
+ }
39
+ return env;
40
+ }
41
+ /**
42
+ * 起一个 PTY。`workspaceRoot` 用来兜底 cwd:即使在别处被误用,也不会在任意目录开 shell。
43
+ */
44
+ export async function startTerminal(options) {
45
+ const safe = resolveInsideRoot(options.workspaceRoot, options.cwd, realpathSync);
46
+ if (!safe.ok)
47
+ return { ok: false, error: safe.reason };
48
+ let spawn = options.spawnImpl;
49
+ if (!spawn) {
50
+ try {
51
+ // 动态 import:node-pty 是原生模块,装不上时整个服务不该崩(ADR-0027 §影响)
52
+ spawn = (await import("node-pty")).spawn;
53
+ }
54
+ catch (error) {
55
+ return {
56
+ ok: false,
57
+ error: `终端不可用(node-pty 未安装或编译失败):${error instanceof Error ? error.message : String(error)}`,
58
+ };
59
+ }
60
+ }
61
+ const size = clampSize(options.cols ?? 80, options.rows ?? 24);
62
+ const shell = process.env.SHELL ?? "/bin/zsh";
63
+ let pty;
64
+ try {
65
+ pty = spawn(shell, ["-l"], {
66
+ name: "xterm-256color",
67
+ cwd: safe.path,
68
+ env: shellEnv(),
69
+ cols: size.cols,
70
+ rows: size.rows,
71
+ });
72
+ }
73
+ catch (error) {
74
+ return { ok: false, error: `起 shell 失败:${error instanceof Error ? error.message : String(error)}` };
75
+ }
76
+ // 输出节流:攒到 30ms 再一起推,单批超 64KB 截断(宁可丢显示,不能拖垮进程)
77
+ let buffer = "";
78
+ let timer = null;
79
+ let killed = false;
80
+ const flush = () => {
81
+ timer = null;
82
+ if (buffer === "")
83
+ return;
84
+ const chunk = buffer.length > MAX_CHUNK_BYTES ? `${buffer.slice(0, MAX_CHUNK_BYTES)}\r\n[输出过快,已截断]\r\n` : buffer;
85
+ buffer = "";
86
+ options.handlers.onData(chunk);
87
+ };
88
+ pty.onData((data) => {
89
+ if (killed)
90
+ return;
91
+ buffer += data;
92
+ if (buffer.length > MAX_CHUNK_BYTES * 4) {
93
+ // 前端根本不读时别无限攒内存:直接丢到上限
94
+ buffer = buffer.slice(-MAX_CHUNK_BYTES);
95
+ }
96
+ if (timer === null)
97
+ timer = setTimeout(flush, OUTPUT_FLUSH_MS);
98
+ });
99
+ pty.onExit(({ exitCode }) => {
100
+ killed = true;
101
+ if (timer !== null)
102
+ clearTimeout(timer);
103
+ timer = null;
104
+ flush();
105
+ options.handlers.onExit(exitCode);
106
+ });
107
+ return {
108
+ ok: true,
109
+ terminal: {
110
+ write: (data) => {
111
+ if (!killed)
112
+ pty.write(data);
113
+ },
114
+ resize: (cols, rows) => {
115
+ if (killed)
116
+ return;
117
+ const next = clampSize(cols, rows);
118
+ try {
119
+ pty.resize(next.cols, next.rows);
120
+ }
121
+ catch {
122
+ // 已经退出的 PTY resize 会抛,忽略即可
123
+ }
124
+ },
125
+ kill: () => {
126
+ if (killed)
127
+ return;
128
+ killed = true;
129
+ try {
130
+ pty.kill();
131
+ }
132
+ catch {
133
+ // 幂等
134
+ }
135
+ },
136
+ cols: () => size.cols,
137
+ rows: () => size.rows,
138
+ },
139
+ };
140
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * 附件上传的 HTTP 路由(ADR-0020 §P3)——从 index.ts 抽出(400 行护栏)。
3
+ *
4
+ * 两条路由:
5
+ * `POST /api/uploads` 原始字节(文件名走 `x-file-name` 头)→ 返回 AttachmentRef
6
+ * `GET /api/uploads/:id` 回显(界面内联显示图片;也是消息里 markdown 引用的落点)
7
+ *
8
+ * 刻意用 `express.raw` 而不是 multipart:少一个依赖,而且我们只需要「一堆字节 + 一个文件名」。
9
+ */
10
+ import express, {} from "express";
11
+ import { isImageMime, MAX_UPLOAD_BYTES } from "./upload-store.js";
12
+ /**
13
+ * 纯函数:解出上传文件名。
14
+ *
15
+ * 两种客户端都要伺候(真实踩过:中文名变成 `æªå¾.png`):
16
+ * - 百分号编码(我们的前端)→ decodeURIComponent;
17
+ * - 原样 UTF-8(curl / 其它工具)→ Node 按 **latin1** 解了请求头,得转回 UTF-8。
18
+ */
19
+ export function decodeFileName(header) {
20
+ const trimmed = header.trim();
21
+ if (trimmed === "")
22
+ return "粘贴的图片";
23
+ if (/%[0-9a-f]{2}/i.test(trimmed)) {
24
+ try {
25
+ return decodeURIComponent(trimmed);
26
+ }
27
+ catch {
28
+ // 落回下面的 latin1 → UTF-8 转换
29
+ }
30
+ }
31
+ const recovered = Buffer.from(trimmed, "latin1").toString("utf8");
32
+ return recovered.includes("\uFFFD") ? trimmed : recovered;
33
+ }
34
+ export function registerUploadRoutes(app, options) {
35
+ app.post("/api/uploads", express.raw({ type: () => true, limit: MAX_UPLOAD_BYTES }), async (request, response) => {
36
+ try {
37
+ const bytes = request.body instanceof Uint8Array ? request.body : new Uint8Array();
38
+ const header = request.headers["x-file-name"];
39
+ const name = decodeFileName(typeof header === "string" ? header : "");
40
+ const saved = await options.uploadStore.save({ bytes, declaredMime: request.headers["content-type"], name });
41
+ options.logger.info("收到上传", { id: saved.id, mime: saved.mime, bytes: saved.bytes });
42
+ response.json({
43
+ id: saved.id,
44
+ kind: isImageMime(saved.mime) ? "image" : "file",
45
+ name: saved.name,
46
+ mime: saved.mime,
47
+ bytes: saved.bytes,
48
+ });
49
+ }
50
+ catch (error) {
51
+ const message = error instanceof Error ? error.message : String(error);
52
+ options.logger.warn("上传被拒", { message });
53
+ response.status(400).json({ error: { code: "upload.rejected", message } });
54
+ }
55
+ });
56
+ app.get("/api/uploads/:id", async (request, response) => {
57
+ const found = await options.uploadStore.read(request.params.id);
58
+ if (!found) {
59
+ response.status(404).json({ error: { code: "upload.not_found", message: "附件不存在或已过期" } });
60
+ return;
61
+ }
62
+ response.type(found.mime).send(found.bytes);
63
+ });
64
+ }