atoo-studio 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.github.md +322 -0
- package/README.md +112 -0
- package/README.npm.md +112 -0
- package/bin/atoo-studio.js +90 -0
- package/dist/src/agents/claude-code-terminal/adapter.d.ts +42 -0
- package/dist/src/agents/claude-code-terminal/adapter.js +166 -0
- package/dist/src/agents/claude-code-terminal/index.d.ts +13 -0
- package/dist/src/agents/claude-code-terminal/index.js +45 -0
- package/dist/src/agents/claude-code-terminal/spawner.d.ts +9 -0
- package/dist/src/agents/claude-code-terminal/spawner.js +37 -0
- package/dist/src/agents/claude-code-terminal-chatro/adapter.d.ts +51 -0
- package/dist/src/agents/claude-code-terminal-chatro/adapter.js +301 -0
- package/dist/src/agents/claude-code-terminal-chatro/index.d.ts +13 -0
- package/dist/src/agents/claude-code-terminal-chatro/index.js +45 -0
- package/dist/src/agents/claude-code-terminal-chatro/jsonl-watcher.d.ts +67 -0
- package/dist/src/agents/claude-code-terminal-chatro/jsonl-watcher.js +431 -0
- package/dist/src/agents/claude-code-terminal-chatro/spawner.d.ts +9 -0
- package/dist/src/agents/claude-code-terminal-chatro/spawner.js +37 -0
- package/dist/src/agents/codex-terminal/adapter.d.ts +40 -0
- package/dist/src/agents/codex-terminal/adapter.js +160 -0
- package/dist/src/agents/codex-terminal/index.d.ts +13 -0
- package/dist/src/agents/codex-terminal/index.js +47 -0
- package/dist/src/agents/codex-terminal/spawner.d.ts +9 -0
- package/dist/src/agents/codex-terminal/spawner.js +56 -0
- package/dist/src/agents/codex-terminal-chatro/adapter.d.ts +58 -0
- package/dist/src/agents/codex-terminal-chatro/adapter.js +266 -0
- package/dist/src/agents/codex-terminal-chatro/index.d.ts +13 -0
- package/dist/src/agents/codex-terminal-chatro/index.js +50 -0
- package/dist/src/agents/codex-terminal-chatro/jsonl-watcher.d.ts +36 -0
- package/dist/src/agents/codex-terminal-chatro/jsonl-watcher.js +205 -0
- package/dist/src/agents/codex-terminal-chatro/spawner.d.ts +9 -0
- package/dist/src/agents/codex-terminal-chatro/spawner.js +57 -0
- package/dist/src/agents/lib/chain-builder.d.ts +21 -0
- package/dist/src/agents/lib/chain-builder.js +139 -0
- package/dist/src/agents/lib/claude/fs-sessions.d.ts +31 -0
- package/dist/src/agents/lib/claude/fs-sessions.js +329 -0
- package/dist/src/agents/lib/claude/jsonl-writer.d.ts +32 -0
- package/dist/src/agents/lib/claude/jsonl-writer.js +342 -0
- package/dist/src/agents/lib/claude/workspace-trust.d.ts +1 -0
- package/dist/src/agents/lib/claude/workspace-trust.js +29 -0
- package/dist/src/agents/lib/codex/fs-sessions.d.ts +34 -0
- package/dist/src/agents/lib/codex/fs-sessions.js +255 -0
- package/dist/src/agents/lib/codex/jsonl-mapper.d.ts +11 -0
- package/dist/src/agents/lib/codex/jsonl-mapper.js +154 -0
- package/dist/src/agents/lib/codex/jsonl-writer.d.ts +8 -0
- package/dist/src/agents/lib/codex/jsonl-writer.js +440 -0
- package/dist/src/agents/lib/fs-tracking.d.ts +36 -0
- package/dist/src/agents/lib/fs-tracking.js +109 -0
- package/dist/src/agents/lib/pty-activity-tracker.d.ts +37 -0
- package/dist/src/agents/lib/pty-activity-tracker.js +105 -0
- package/dist/src/agents/lib/session-id-utils.d.ts +46 -0
- package/dist/src/agents/lib/session-id-utils.js +147 -0
- package/dist/src/agents/lib/session-precreate.d.ts +17 -0
- package/dist/src/agents/lib/session-precreate.js +177 -0
- package/dist/src/agents/registry.d.ts +72 -0
- package/dist/src/agents/registry.js +337 -0
- package/dist/src/agents/types.d.ts +135 -0
- package/dist/src/agents/types.js +1 -0
- package/dist/src/auth/crypto-key.d.ts +6 -0
- package/dist/src/auth/crypto-key.js +45 -0
- package/dist/src/auth/middleware.d.ts +18 -0
- package/dist/src/auth/middleware.js +54 -0
- package/dist/src/auth/password.d.ts +2 -0
- package/dist/src/auth/password.js +12 -0
- package/dist/src/auth/session.d.ts +10 -0
- package/dist/src/auth/session.js +33 -0
- package/dist/src/auth/totp.d.ts +12 -0
- package/dist/src/auth/totp.js +61 -0
- package/dist/src/auth/webauthn.d.ts +6 -0
- package/dist/src/auth/webauthn.js +117 -0
- package/dist/src/config.d.ts +10 -0
- package/dist/src/config.js +16 -0
- package/dist/src/database/connection-manager.d.ts +25 -0
- package/dist/src/database/connection-manager.js +211 -0
- package/dist/src/database/discovery/container.d.ts +6 -0
- package/dist/src/database/discovery/container.js +226 -0
- package/dist/src/database/discovery/env-parser.d.ts +9 -0
- package/dist/src/database/discovery/env-parser.js +525 -0
- package/dist/src/database/discovery/local-files.d.ts +6 -0
- package/dist/src/database/discovery/local-files.js +58 -0
- package/dist/src/database/discovery/port-scan.d.ts +7 -0
- package/dist/src/database/discovery/port-scan.js +61 -0
- package/dist/src/database/drivers/cassandra.d.ts +12 -0
- package/dist/src/database/drivers/cassandra.js +91 -0
- package/dist/src/database/drivers/clickhouse.d.ts +11 -0
- package/dist/src/database/drivers/clickhouse.js +127 -0
- package/dist/src/database/drivers/elasticsearch.d.ts +12 -0
- package/dist/src/database/drivers/elasticsearch.js +169 -0
- package/dist/src/database/drivers/influxdb.d.ts +14 -0
- package/dist/src/database/drivers/influxdb.js +194 -0
- package/dist/src/database/drivers/memcached.d.ts +11 -0
- package/dist/src/database/drivers/memcached.js +117 -0
- package/dist/src/database/drivers/mongodb.d.ts +12 -0
- package/dist/src/database/drivers/mongodb.js +128 -0
- package/dist/src/database/drivers/mysql.d.ts +11 -0
- package/dist/src/database/drivers/mysql.js +112 -0
- package/dist/src/database/drivers/neo4j.d.ts +11 -0
- package/dist/src/database/drivers/neo4j.js +158 -0
- package/dist/src/database/drivers/postgresql.d.ts +11 -0
- package/dist/src/database/drivers/postgresql.js +133 -0
- package/dist/src/database/drivers/redis.d.ts +11 -0
- package/dist/src/database/drivers/redis.js +91 -0
- package/dist/src/database/drivers/sqlite.d.ts +10 -0
- package/dist/src/database/drivers/sqlite.js +100 -0
- package/dist/src/database/query-stream.d.ts +5 -0
- package/dist/src/database/query-stream.js +75 -0
- package/dist/src/database/types.d.ts +71 -0
- package/dist/src/database/types.js +1 -0
- package/dist/src/events/index.d.ts +3 -0
- package/dist/src/events/index.js +3 -0
- package/dist/src/events/types.d.ts +214 -0
- package/dist/src/events/types.js +22 -0
- package/dist/src/events/wire.d.ts +114 -0
- package/dist/src/events/wire.js +296 -0
- package/dist/src/fs-monitor-types.d.ts +24 -0
- package/dist/src/fs-monitor-types.js +1 -0
- package/dist/src/fs-monitor.d.ts +80 -0
- package/dist/src/fs-monitor.js +637 -0
- package/dist/src/handlers/auth.d.ts +1 -0
- package/dist/src/handlers/auth.js +170 -0
- package/dist/src/handlers/changes.d.ts +1 -0
- package/dist/src/handlers/changes.js +203 -0
- package/dist/src/handlers/containers.d.ts +12 -0
- package/dist/src/handlers/containers.js +379 -0
- package/dist/src/handlers/databases.d.ts +3 -0
- package/dist/src/handlers/databases.js +327 -0
- package/dist/src/handlers/environments.d.ts +3 -0
- package/dist/src/handlers/environments.js +286 -0
- package/dist/src/handlers/github.d.ts +1 -0
- package/dist/src/handlers/github.js +153 -0
- package/dist/src/handlers/projects.d.ts +1 -0
- package/dist/src/handlers/projects.js +895 -0
- package/dist/src/handlers/ssh.d.ts +1 -0
- package/dist/src/handlers/ssh.js +162 -0
- package/dist/src/handlers/users.d.ts +1 -0
- package/dist/src/handlers/users.js +195 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +228 -0
- package/dist/src/mcp/config.d.ts +32 -0
- package/dist/src/mcp/config.js +227 -0
- package/dist/src/mcp/server.d.ts +1 -0
- package/dist/src/mcp/server.js +574 -0
- package/dist/src/serial/cuse-device.d.ts +19 -0
- package/dist/src/serial/cuse-device.js +260 -0
- package/dist/src/serial/manager.d.ts +63 -0
- package/dist/src/serial/manager.js +206 -0
- package/dist/src/serial/pty-pair.d.ts +16 -0
- package/dist/src/serial/pty-pair.js +68 -0
- package/dist/src/services/fs-browser.d.ts +14 -0
- package/dist/src/services/fs-browser.js +98 -0
- package/dist/src/services/git-ops.d.ts +78 -0
- package/dist/src/services/git-ops.js +288 -0
- package/dist/src/services/github-ops.d.ts +104 -0
- package/dist/src/services/github-ops.js +192 -0
- package/dist/src/services/obfuscation.d.ts +2 -0
- package/dist/src/services/obfuscation.js +16 -0
- package/dist/src/services/preview/headless-backend.d.ts +62 -0
- package/dist/src/services/preview/headless-backend.js +698 -0
- package/dist/src/services/preview/injected-scripts.d.ts +9 -0
- package/dist/src/services/preview/injected-scripts.js +232 -0
- package/dist/src/services/preview/preview-backend.d.ts +92 -0
- package/dist/src/services/preview/preview-backend.js +15 -0
- package/dist/src/services/preview/universal-setter.d.ts +7 -0
- package/dist/src/services/preview/universal-setter.js +46 -0
- package/dist/src/services/preview-manager.d.ts +50 -0
- package/dist/src/services/preview-manager.js +216 -0
- package/dist/src/services/project-watcher.d.ts +6 -0
- package/dist/src/services/project-watcher.js +307 -0
- package/dist/src/services/remote-fs-browser.d.ts +11 -0
- package/dist/src/services/remote-fs-browser.js +50 -0
- package/dist/src/services/remote-git-ops.d.ts +71 -0
- package/dist/src/services/remote-git-ops.js +215 -0
- package/dist/src/services/session-search.d.ts +56 -0
- package/dist/src/services/session-search.js +303 -0
- package/dist/src/services/ssh-manager.d.ts +44 -0
- package/dist/src/services/ssh-manager.js +359 -0
- package/dist/src/session-writer.d.ts +9 -0
- package/dist/src/session-writer.js +66 -0
- package/dist/src/spawner.d.ts +56 -0
- package/dist/src/spawner.js +135 -0
- package/dist/src/state/db.d.ts +214 -0
- package/dist/src/state/db.js +897 -0
- package/dist/src/state/store.d.ts +37 -0
- package/dist/src/state/store.js +108 -0
- package/dist/src/state/types.d.ts +13 -0
- package/dist/src/state/types.js +1 -0
- package/dist/src/web/devtools-proxy.d.ts +7 -0
- package/dist/src/web/devtools-proxy.js +176 -0
- package/dist/src/web/port-proxy.d.ts +15 -0
- package/dist/src/web/port-proxy.js +124 -0
- package/dist/src/web/preview-ws.d.ts +5 -0
- package/dist/src/web/preview-ws.js +207 -0
- package/dist/src/web/server.d.ts +6 -0
- package/dist/src/web/server.js +1694 -0
- package/dist/src/ws/agent-ws.d.ts +5 -0
- package/dist/src/ws/agent-ws.js +93 -0
- package/frontend/dist/assets/_basePickBy-B-LibQ4-.js +1 -0
- package/frontend/dist/assets/_baseUniq-CprifHap.js +1 -0
- package/frontend/dist/assets/_createAssigner-ByDUqGii.js +1 -0
- package/frontend/dist/assets/abap-DuT-3z4x.js +1 -0
- package/frontend/dist/assets/addon-fit-CxQet2ja.js +1 -0
- package/frontend/dist/assets/addon-web-links-D_jRkPIl.js +1 -0
- package/frontend/dist/assets/apex-B-em86xX.js +1 -0
- package/frontend/dist/assets/api-SUPuHhSY.js +2 -0
- package/frontend/dist/assets/arc-Z0_eVteO.js +1 -0
- package/frontend/dist/assets/architecture-PBZL5I3N-hvVXGhqd.js +1 -0
- package/frontend/dist/assets/architectureDiagram-2XIMDMQ5-DiHPxX4j.js +36 -0
- package/frontend/dist/assets/array-CwG8vNfn.js +1 -0
- package/frontend/dist/assets/auth-store-R7eW5SVu.js +1 -0
- package/frontend/dist/assets/azcli-Bg9wQloi.js +1 -0
- package/frontend/dist/assets/bat-BM46z99L.js +1 -0
- package/frontend/dist/assets/bicep-DcBsJUfh.js +2 -0
- package/frontend/dist/assets/blockDiagram-WCTKOSBZ-C40u_hLo.js +132 -0
- package/frontend/dist/assets/c4Diagram-IC4MRINW-Ct7LjWFQ.js +10 -0
- package/frontend/dist/assets/cameligo-zw7JTtim.js +1 -0
- package/frontend/dist/assets/channel-ClCsE6HN.js +1 -0
- package/frontend/dist/assets/chunk-4BX2VUAB-zZ6P90VO.js +1 -0
- package/frontend/dist/assets/chunk-55IACEB6-DXllTDQl.js +1 -0
- package/frontend/dist/assets/chunk-7E7YKBS2-7zRaOLjj.js +1 -0
- package/frontend/dist/assets/chunk-7R4GIKGN-Csst1274.js +80 -0
- package/frontend/dist/assets/chunk-C72U2L5F-_JbQPbLN.js +1 -0
- package/frontend/dist/assets/chunk-CFjPhJqf.js +1 -0
- package/frontend/dist/assets/chunk-EGIJ26TM-B--aFyPw.js +1 -0
- package/frontend/dist/assets/chunk-FMBD7UC4-DVR34RNb.js +15 -0
- package/frontend/dist/assets/chunk-GEFDOKGD-CnmN6cC8.js +2 -0
- package/frontend/dist/assets/chunk-JSJVCQXG-CWxHBzeJ.js +1 -0
- package/frontend/dist/assets/chunk-KX2RTZJC-DkRk56s7.js +1 -0
- package/frontend/dist/assets/chunk-KYZI473N-DCCsG2dK.js +53 -0
- package/frontend/dist/assets/chunk-L3YUKLVL-C-DkZTMr.js +1 -0
- package/frontend/dist/assets/chunk-MX3YWQON-OUdzv5sZ.js +1 -0
- package/frontend/dist/assets/chunk-NQ4KR5QH-Bpu9FsM7.js +220 -0
- package/frontend/dist/assets/chunk-O4XLMI2P-BMLK6_ib.js +7 -0
- package/frontend/dist/assets/chunk-OZEHJAEY-CNNiJtG0.js +1 -0
- package/frontend/dist/assets/chunk-PQ6SQG4A-evVHD3KM.js +1 -0
- package/frontend/dist/assets/chunk-PU5JKC2W-DPFTYuvl.js +70 -0
- package/frontend/dist/assets/chunk-QZHKN3VN-JRdddPvu.js +1 -0
- package/frontend/dist/assets/chunk-R5LLSJPH-CHQzVVOV.js +1 -0
- package/frontend/dist/assets/chunk-WL4C6EOR-BNFU6IIi.js +189 -0
- package/frontend/dist/assets/chunk-XIRO2GV7-98T93G85.js +1 -0
- package/frontend/dist/assets/chunk-XZSTWKYB-BcW3cyNp.js +94 -0
- package/frontend/dist/assets/chunk-YBOYWFTD-BgKO1qAJ.js +1 -0
- package/frontend/dist/assets/classDiagram-VBA2DB6C-DikXzgcD.js +1 -0
- package/frontend/dist/assets/classDiagram-v2-RAHNMMFH-D7E3tQUK.js +1 -0
- package/frontend/dist/assets/clojure-FspFoNNQ.js +1 -0
- package/frontend/dist/assets/clone-mOXuZa7C.js +1 -0
- package/frontend/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
- package/frontend/dist/assets/coffee-13n8Bk2W.js +1 -0
- package/frontend/dist/assets/cose-bilkent-S5V4N54A-zUOWQqLe.js +1 -0
- package/frontend/dist/assets/cpp-BVm2xGEs.js +1 -0
- package/frontend/dist/assets/csharp-D2kAWmUm.js +1 -0
- package/frontend/dist/assets/csp-Ezvgpf0e.js +1 -0
- package/frontend/dist/assets/css-CYxRwcFy.js +3 -0
- package/frontend/dist/assets/css.worker-Cd5h-ZOL.js +89 -0
- package/frontend/dist/assets/cssMode-CrXej49V.js +1 -0
- package/frontend/dist/assets/cypher-jg3SGErc.js +1 -0
- package/frontend/dist/assets/cytoscape.esm-kyyvzxNV.js +321 -0
- package/frontend/dist/assets/dagre-DH4bgZO7.js +1 -0
- package/frontend/dist/assets/dagre-KLK3FWXG-DNSqDkwT.js +4 -0
- package/frontend/dist/assets/dart-179jqhK4.js +1 -0
- package/frontend/dist/assets/defaultLocale-Dda4OpKy.js +1 -0
- package/frontend/dist/assets/diagram-E7M64L7V-RqPNT5Vs.js +24 -0
- package/frontend/dist/assets/diagram-IFDJBPK2-B-5NRyaE.js +43 -0
- package/frontend/dist/assets/diagram-P4PSJMXO-BrP69Hk0.js +24 -0
- package/frontend/dist/assets/dist-CU_Nb1G5.js +1 -0
- package/frontend/dist/assets/dockerfile-CIAtSGxS.js +1 -0
- package/frontend/dist/assets/ecl-CGVKfDxD.js +1 -0
- package/frontend/dist/assets/editor-Br_kD0ds.css +1 -0
- package/frontend/dist/assets/editor.api2-YXkDn0Gm.js +872 -0
- package/frontend/dist/assets/editor.main-fBaXZjJ0.js +6 -0
- package/frontend/dist/assets/elixir-BZ-6w0y3.js +1 -0
- package/frontend/dist/assets/erDiagram-INFDFZHY-BYiB9NYg.js +70 -0
- package/frontend/dist/assets/flow9-CVuOjTMv.js +1 -0
- package/frontend/dist/assets/flowDiagram-PKNHOUZH-Cwq47rsR.js +162 -0
- package/frontend/dist/assets/freemarker2-DM-pztJU.js +3 -0
- package/frontend/dist/assets/fsharp-q0pGJYr6.js +1 -0
- package/frontend/dist/assets/ganttDiagram-A5KZAMGK-Dnx3szD9.js +292 -0
- package/frontend/dist/assets/gitGraph-HDMCJU4V-COlTQ7bA.js +1 -0
- package/frontend/dist/assets/gitGraphDiagram-K3NZZRJ6-BaUxboNc.js +65 -0
- package/frontend/dist/assets/go-dzSPfdEO.js +1 -0
- package/frontend/dist/assets/graphlib-kEFlkt3U.js +1 -0
- package/frontend/dist/assets/graphql-CG4OUoEV.js +1 -0
- package/frontend/dist/assets/handlebars-BbK53Vec.js +1 -0
- package/frontend/dist/assets/hcl-Cy14JPk3.js +1 -0
- package/frontend/dist/assets/html-DYtTQNOG.js +1 -0
- package/frontend/dist/assets/html.worker-BjVEKLoU.js +502 -0
- package/frontend/dist/assets/htmlMode-C6GTouth.js +1 -0
- package/frontend/dist/assets/index-DMLxes_u.js +157 -0
- package/frontend/dist/assets/index-DmzeqkB1.css +1 -0
- package/frontend/dist/assets/info-3K5VOQVL-DBtHyA4C.js +1 -0
- package/frontend/dist/assets/infoDiagram-LFFYTUFH-yBXLgMPI.js +2 -0
- package/frontend/dist/assets/ini-Pbg8HGVD.js +1 -0
- package/frontend/dist/assets/init-D6KNwrax.js +1 -0
- package/frontend/dist/assets/ishikawaDiagram-PHBUUO56-Bld4two_.js +70 -0
- package/frontend/dist/assets/java-BmVu6Qrl.js +1 -0
- package/frontend/dist/assets/javascript-PbfQEdcJ.js +1 -0
- package/frontend/dist/assets/journeyDiagram-4ABVD52K-4HyMd4R2.js +139 -0
- package/frontend/dist/assets/json.worker-DqU5Wxnl.js +58 -0
- package/frontend/dist/assets/jsonMode-CASsGppE.js +7 -0
- package/frontend/dist/assets/julia-3cGnieBq.js +1 -0
- package/frontend/dist/assets/kanban-definition-K7BYSVSG-DpgsZmpG.js +89 -0
- package/frontend/dist/assets/katex-CEw3x5bf.js +261 -0
- package/frontend/dist/assets/kotlin-BuWkVcfV.js +1 -0
- package/frontend/dist/assets/less-CJ_VPy2C.js +2 -0
- package/frontend/dist/assets/lexon-BygAuZPu.js +1 -0
- package/frontend/dist/assets/line-CA_wh_TY.js +1 -0
- package/frontend/dist/assets/linear-BAcLW45z.js +1 -0
- package/frontend/dist/assets/liquid-kz84dle6.js +1 -0
- package/frontend/dist/assets/lspLanguageFeatures-C7hAHFn1.js +4 -0
- package/frontend/dist/assets/lua-C8Xs3dCx.js +1 -0
- package/frontend/dist/assets/m3-DTJeKBk4.js +1 -0
- package/frontend/dist/assets/markdown-QCgx8JqZ.js +1 -0
- package/frontend/dist/assets/math-D0YcMJAn.js +1 -0
- package/frontend/dist/assets/mdx-yRw0ap-E.js +1 -0
- package/frontend/dist/assets/mermaid-parser.core-DAeTodBQ.js +4 -0
- package/frontend/dist/assets/mindmap-definition-YRQLILUH-CoNlFyVl.js +68 -0
- package/frontend/dist/assets/mips-DopWaYgE.js +1 -0
- package/frontend/dist/assets/monaco.contribution-DeY0Qei-.js +2 -0
- package/frontend/dist/assets/msdax-BDis4ARV.js +1 -0
- package/frontend/dist/assets/mysql-BV6MLsOI.js +1 -0
- package/frontend/dist/assets/objective-c-B1UuzKs6.js +1 -0
- package/frontend/dist/assets/ordinal-jM7S0YHN.js +1 -0
- package/frontend/dist/assets/packet-RMMSAZCW-FF6-Tmai.js +1 -0
- package/frontend/dist/assets/pascal-BkvESCrc.js +1 -0
- package/frontend/dist/assets/pascaligo-lTy0kZYr.js +1 -0
- package/frontend/dist/assets/path-DNPd7Py7.js +1 -0
- package/frontend/dist/assets/perl-CrtUPXLV.js +1 -0
- package/frontend/dist/assets/pgsql-B9IbNWx2.js +1 -0
- package/frontend/dist/assets/php-CXvQBY2p.js +1 -0
- package/frontend/dist/assets/pie-UPGHQEXC-CFvXY2o-.js +1 -0
- package/frontend/dist/assets/pieDiagram-SKSYHLDU-CM_hbCcn.js +30 -0
- package/frontend/dist/assets/pla-DxBxuqWu.js +1 -0
- package/frontend/dist/assets/postiats-OkEuT5YF.js +1 -0
- package/frontend/dist/assets/powerquery-CMx5Tq4K.js +1 -0
- package/frontend/dist/assets/powershell-CstRxrEc.js +1 -0
- package/frontend/dist/assets/preload-helper-D4M6sveU.js +1 -0
- package/frontend/dist/assets/protobuf-Bx0Z-uRj.js +2 -0
- package/frontend/dist/assets/pug--W8vanWl.js +1 -0
- package/frontend/dist/assets/python-DA0rnlw3.js +1 -0
- package/frontend/dist/assets/qsharp-CRtr0YbN.js +1 -0
- package/frontend/dist/assets/quadrantDiagram-337W2JSQ-B3n3IUhC.js +7 -0
- package/frontend/dist/assets/r-C6E1d6iv.js +1 -0
- package/frontend/dist/assets/radar-KQ55EAFF-MPZu7SdX.js +1 -0
- package/frontend/dist/assets/razor-yd73uata.js +1 -0
- package/frontend/dist/assets/redis-Dx13voP3.js +1 -0
- package/frontend/dist/assets/redshift-D66HwlyV.js +1 -0
- package/frontend/dist/assets/requirementDiagram-Z7DCOOCP-CorP7L7F.js +73 -0
- package/frontend/dist/assets/restructuredtext-DQT2NKJ2.js +1 -0
- package/frontend/dist/assets/rough.esm-DxAX5Vpo.js +1 -0
- package/frontend/dist/assets/ruby-iFXI8hwH.js +1 -0
- package/frontend/dist/assets/rust-CSKiei34.js +1 -0
- package/frontend/dist/assets/sankeyDiagram-WA2Y5GQK-RDx6Bd-B.js +10 -0
- package/frontend/dist/assets/sb-Bo3ttdP2.js +1 -0
- package/frontend/dist/assets/scala-BC1D-Nxp.js +1 -0
- package/frontend/dist/assets/scheme-Z4OAo4Lv.js +1 -0
- package/frontend/dist/assets/scss-BvrdPs6B.js +3 -0
- package/frontend/dist/assets/sequenceDiagram-2WXFIKYE-JMqJSFq6.js +145 -0
- package/frontend/dist/assets/shell-Bh_aCyF-.js +1 -0
- package/frontend/dist/assets/solidity-CWHj6tSe.js +1 -0
- package/frontend/dist/assets/sophia-raoNtKtm.js +1 -0
- package/frontend/dist/assets/sparql-XzmoGnue.js +1 -0
- package/frontend/dist/assets/sql-BD0i9Gvg.js +1 -0
- package/frontend/dist/assets/src-Bn-kKzs7.js +1 -0
- package/frontend/dist/assets/st-DtVKyms6.js +1 -0
- package/frontend/dist/assets/stateDiagram-RAJIS63D-CgFfENdy.js +1 -0
- package/frontend/dist/assets/stateDiagram-v2-FVOUBMTO-C4Hh2P-U.js +1 -0
- package/frontend/dist/assets/swift--UZs77wT.js +1 -0
- package/frontend/dist/assets/systemverilog-CDnBSWUd.js +1 -0
- package/frontend/dist/assets/tcl-DdCEuTHZ.js +1 -0
- package/frontend/dist/assets/timeline-definition-YZTLITO2-BnatPBR5.js +61 -0
- package/frontend/dist/assets/treemap-KZPCXAKY-qb1Pl9la.js +1 -0
- package/frontend/dist/assets/ts.worker-DyPAEIuH.js +67719 -0
- package/frontend/dist/assets/tsMode-iuvyEpyO.js +11 -0
- package/frontend/dist/assets/twig-SSL-Altf.js +1 -0
- package/frontend/dist/assets/typescript-17918Hud.js +1 -0
- package/frontend/dist/assets/typespec-BT7S0ETg.js +1 -0
- package/frontend/dist/assets/vb-CrIgucua.js +1 -0
- package/frontend/dist/assets/vennDiagram-LZ73GAT5-DygS4Zzd.js +34 -0
- package/frontend/dist/assets/wgsl-BeKc3oEp.js +298 -0
- package/frontend/dist/assets/workers-DTfwKVoM.js +1 -0
- package/frontend/dist/assets/xml-CBMr_Wbw.js +1 -0
- package/frontend/dist/assets/xterm-BrP-ENHg.css +1 -0
- package/frontend/dist/assets/xterm-CBX2m0YM.js +36 -0
- package/frontend/dist/assets/xychartDiagram-JWTSCODW-D6wY1Jwd.js +7 -0
- package/frontend/dist/assets/yaml-CTjCH7Bv.js +1 -0
- package/frontend/dist/fonts/inter-300.ttf +0 -0
- package/frontend/dist/fonts/inter-400.ttf +0 -0
- package/frontend/dist/fonts/inter-500.ttf +0 -0
- package/frontend/dist/fonts/inter-600.ttf +0 -0
- package/frontend/dist/fonts/inter-700.ttf +0 -0
- package/frontend/dist/index.html +49 -0
- package/frontend/dist/logo_192x192.png +0 -0
- package/frontend/dist/logo_32x32.png +0 -0
- package/frontend/dist/logo_512x512.png +0 -0
- package/frontend/dist/logo_64x64.png +0 -0
- package/frontend/dist/logobg_192x192.png +0 -0
- package/frontend/dist/logobg_512x512.png +0 -0
- package/frontend/dist/logobg_64x64.png +0 -0
- package/frontend/dist/manifest.json +25 -0
- package/frontend/dist/sw.js +22 -0
- package/package.json +74 -7
- package/preload/Makefile +12 -0
- package/preload/atoo-studio-preload.c +647 -0
- package/preload/atoo-studio-preload.so +0 -0
- package/setup-cuse.sh +260 -0
- package/setup.sh +81 -0
- package/src/serial/native/binding.gyp +10 -0
- package/src/serial/native/pty_pair.c +222 -0
|
@@ -0,0 +1,1694 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { WebSocketServer } from 'ws';
|
|
3
|
+
import http from 'http';
|
|
4
|
+
import https from 'https';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import os from 'os';
|
|
8
|
+
import { execSync } from 'child_process';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
10
|
+
import cookieParser from 'cookie-parser';
|
|
11
|
+
import { store } from '../state/store.js';
|
|
12
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
13
|
+
import * as pty from 'node-pty';
|
|
14
|
+
import { getPty, getEnvIdForSession, getScrollback } from '../spawner.js';
|
|
15
|
+
import { fsMonitor } from '../fs-monitor.js';
|
|
16
|
+
import { changesRouter } from '../handlers/changes.js';
|
|
17
|
+
import { projectsRouter } from '../handlers/projects.js';
|
|
18
|
+
import { environmentsRouter, setBroadcastSettingsChange } from '../handlers/environments.js';
|
|
19
|
+
import { sshRouter } from '../handlers/ssh.js';
|
|
20
|
+
import { githubRouter } from '../handlers/github.js';
|
|
21
|
+
import { authRouter } from '../handlers/auth.js';
|
|
22
|
+
import { usersRouter } from '../handlers/users.js';
|
|
23
|
+
import { isAgentWsUpgrade, handleAgentWsUpgrade } from '../ws/agent-ws.js';
|
|
24
|
+
import { agentRegistry } from '../agents/registry.js';
|
|
25
|
+
import { createPortProxy, portProxyMiddleware, isPortProxyUpgrade, handlePortProxyUpgrade } from './port-proxy.js';
|
|
26
|
+
import { isPreviewWsUpgrade, handlePreviewWsUpgrade } from './preview-ws.js';
|
|
27
|
+
import { previewManager } from '../services/preview-manager.js';
|
|
28
|
+
import { devtoolsProxyMiddleware, isDevtoolsWsUpgrade, handleDevtoolsWsUpgrade } from './devtools-proxy.js';
|
|
29
|
+
import forge from 'node-forge';
|
|
30
|
+
import { CA_CERT_PATH, CA_KEY_PATH, PROJECT_ROOT } from '../config.js';
|
|
31
|
+
import { serialManager } from '../serial/manager.js';
|
|
32
|
+
import { searchSessionHistory, fetchSessionRange } from '../services/session-search.js';
|
|
33
|
+
import { resolveChainHead, walkChain } from '../agents/lib/session-id-utils.js';
|
|
34
|
+
import { db } from '../state/db.js';
|
|
35
|
+
import { containersRouter, getContainerRuntimes } from '../handlers/containers.js';
|
|
36
|
+
import { databasesRouter, handleMcpConnectDatabase } from '../handlers/databases.js';
|
|
37
|
+
import { isDatabaseWsUpgrade, handleDatabaseWsUpgrade } from '../database/query-stream.js';
|
|
38
|
+
import { requireAuth, authenticateWsUpgrade, isAuthEnabled } from '../auth/middleware.js';
|
|
39
|
+
import { validateMcpToken } from '../mcp/config.js';
|
|
40
|
+
// Standalone shell terminals (not tied to Claude sessions)
|
|
41
|
+
const shellTerminals = new Map();
|
|
42
|
+
// Broadcast registries for multi-browser terminal/shell connections
|
|
43
|
+
// Each entry keeps a scrollback buffer so late-joining browsers get existing output
|
|
44
|
+
const MAX_SCROLLBACK = 200_000; // characters
|
|
45
|
+
const terminalClients = new Map();
|
|
46
|
+
const shellClients = new Map();
|
|
47
|
+
const pendingSessionSwitches = new Map();
|
|
48
|
+
const pendingOpenFiles = new Map();
|
|
49
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
50
|
+
export function createWebServer(tlsOptions) {
|
|
51
|
+
const app = express();
|
|
52
|
+
// Port-proxy: intercept before body parsing so proxied requests stream through
|
|
53
|
+
const portProxy = createPortProxy();
|
|
54
|
+
app.use(portProxyMiddleware(portProxy));
|
|
55
|
+
app.use(express.json({ limit: '50mb' }));
|
|
56
|
+
app.use(cookieParser());
|
|
57
|
+
// Auth routes (public — handles its own auth checks internally)
|
|
58
|
+
app.use(authRouter);
|
|
59
|
+
app.use(usersRouter);
|
|
60
|
+
// Serve CA certificate for trust installation (PEM for Linux/macOS, DER .crt for Windows)
|
|
61
|
+
app.get('/ca.pem', (_req, res) => {
|
|
62
|
+
try {
|
|
63
|
+
const caPem = fs.readFileSync(CA_CERT_PATH, 'utf-8');
|
|
64
|
+
res.setHeader('Content-Type', 'application/x-pem-file');
|
|
65
|
+
res.setHeader('Content-Disposition', 'attachment; filename="atoo-studio-ca.pem"');
|
|
66
|
+
res.send(caPem);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
res.status(500).json({ error: 'CA certificate not found' });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
app.get('/ca.crt', (_req, res) => {
|
|
73
|
+
try {
|
|
74
|
+
const caPem = fs.readFileSync(CA_CERT_PATH, 'utf-8');
|
|
75
|
+
res.setHeader('Content-Type', 'application/x-x509-ca-cert');
|
|
76
|
+
res.setHeader('Content-Disposition', 'attachment; filename="atoo-studio-ca.crt"');
|
|
77
|
+
res.send(caPem);
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
res.status(500).json({ error: 'CA certificate not found' });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
// ═══════════════════════════════════════════════════════
|
|
84
|
+
// MCP middleware — localhost check + per-session token
|
|
85
|
+
// ═══════════════════════════════════════════════════════
|
|
86
|
+
app.use('/api/mcp', (req, res, next) => {
|
|
87
|
+
// Localhost check
|
|
88
|
+
const addr = req.socket.remoteAddress;
|
|
89
|
+
if (addr !== '127.0.0.1' && addr !== '::1' && addr !== '::ffff:127.0.0.1') {
|
|
90
|
+
res.status(403).json({ error: 'MCP callbacks are localhost-only' });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// Token check
|
|
94
|
+
const authHeader = req.headers.authorization;
|
|
95
|
+
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
|
96
|
+
if (!token || !validateMcpToken(token)) {
|
|
97
|
+
res.status(401).json({ error: 'Invalid or missing MCP token' });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
next();
|
|
101
|
+
});
|
|
102
|
+
// MCP callback: notify that a GitHub issue/PR was changed
|
|
103
|
+
app.post('/api/mcp/github-changed', (req, res) => {
|
|
104
|
+
const { repository, type, number, sessionUuid } = req.body;
|
|
105
|
+
if (!repository || typeof repository !== 'string') {
|
|
106
|
+
return res.status(400).json({ error: 'repository is required' });
|
|
107
|
+
}
|
|
108
|
+
if (type !== 'issue' && type !== 'pr') {
|
|
109
|
+
return res.status(400).json({ error: 'type must be "issue" or "pr"' });
|
|
110
|
+
}
|
|
111
|
+
if (!number || typeof number !== 'number') {
|
|
112
|
+
return res.status(400).json({ error: 'number is required' });
|
|
113
|
+
}
|
|
114
|
+
console.log(`[mcp] github-changed: ${type} #${number} in ${repository}${sessionUuid ? ` (session ${sessionUuid})` : ''}`);
|
|
115
|
+
const msg = JSON.stringify({ type: 'github_issue_pr_changed', repository, itemType: type, number, ...(sessionUuid ? { sessionUuid } : {}) });
|
|
116
|
+
for (const ws of store.statusClients) {
|
|
117
|
+
if (ws.readyState === 1)
|
|
118
|
+
ws.send(msg);
|
|
119
|
+
}
|
|
120
|
+
res.json({ success: true });
|
|
121
|
+
});
|
|
122
|
+
// MCP callback: report started TCP services
|
|
123
|
+
app.post('/api/mcp/report-services', (req, res) => {
|
|
124
|
+
const { services, cwd } = req.body;
|
|
125
|
+
if (!Array.isArray(services) || !services.length) {
|
|
126
|
+
return res.status(400).json({ error: 'services array is required' });
|
|
127
|
+
}
|
|
128
|
+
console.log(`[mcp] report-services: ${services.length} service(s) from ${cwd}`);
|
|
129
|
+
const msg = JSON.stringify({ type: 'service_started', services, cwd });
|
|
130
|
+
for (const ws of store.statusClients) {
|
|
131
|
+
if (ws.readyState === 1)
|
|
132
|
+
ws.send(msg);
|
|
133
|
+
}
|
|
134
|
+
res.json({ success: true });
|
|
135
|
+
});
|
|
136
|
+
// MCP callback: generate a certificate signed by the proxy CA and write to disk
|
|
137
|
+
app.post('/api/mcp/generate-cert', (req, res) => {
|
|
138
|
+
const { hostnames, output_dir } = req.body;
|
|
139
|
+
if (!Array.isArray(hostnames) || !hostnames.length) {
|
|
140
|
+
return res.status(400).json({ error: 'hostnames array is required' });
|
|
141
|
+
}
|
|
142
|
+
if (!output_dir || typeof output_dir !== 'string') {
|
|
143
|
+
return res.status(400).json({ error: 'output_dir is required' });
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const caCertPem = fs.readFileSync(CA_CERT_PATH, 'utf-8');
|
|
147
|
+
const caKeyPem = fs.readFileSync(CA_KEY_PATH, 'utf-8');
|
|
148
|
+
const caCert = forge.pki.certificateFromPem(caCertPem);
|
|
149
|
+
const caKey = forge.pki.privateKeyFromPem(caKeyPem);
|
|
150
|
+
const keys = forge.pki.rsa.generateKeyPair(2048);
|
|
151
|
+
const cert = forge.pki.createCertificate();
|
|
152
|
+
cert.publicKey = keys.publicKey;
|
|
153
|
+
cert.serialNumber = Date.now().toString(16);
|
|
154
|
+
cert.validity.notBefore = new Date();
|
|
155
|
+
cert.validity.notAfter = new Date();
|
|
156
|
+
cert.validity.notAfter.setFullYear(cert.validity.notAfter.getFullYear() + 1);
|
|
157
|
+
cert.setSubject([{ name: 'commonName', value: hostnames[0] }]);
|
|
158
|
+
cert.setIssuer(caCert.subject.attributes);
|
|
159
|
+
cert.setExtensions([
|
|
160
|
+
{ name: 'basicConstraints', cA: false },
|
|
161
|
+
{ name: 'subjectAltName', altNames: hostnames.map((h) => ({ type: 2, value: h })) },
|
|
162
|
+
{ name: 'keyUsage', digitalSignature: true, keyEncipherment: true },
|
|
163
|
+
{ name: 'extKeyUsage', serverAuth: true },
|
|
164
|
+
]);
|
|
165
|
+
cert.sign(caKey, forge.md.sha256.create());
|
|
166
|
+
fs.mkdirSync(output_dir, { recursive: true });
|
|
167
|
+
const certPath = path.join(output_dir, 'cert.pem');
|
|
168
|
+
const keyPath = path.join(output_dir, 'key.pem');
|
|
169
|
+
const caPath = path.join(output_dir, 'ca.pem');
|
|
170
|
+
fs.writeFileSync(certPath, forge.pki.certificateToPem(cert));
|
|
171
|
+
fs.writeFileSync(keyPath, forge.pki.privateKeyToPem(keys.privateKey));
|
|
172
|
+
fs.writeFileSync(caPath, caCertPem);
|
|
173
|
+
console.log(`[mcp] Generated certificate for ${hostnames.join(', ')} → ${output_dir}`);
|
|
174
|
+
res.json({ cert_path: certPath, key_path: keyPath, ca_path: caPath });
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
res.status(500).json({ error: err.message });
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
// MCP callback: request serial device passthrough
|
|
181
|
+
app.post('/api/mcp/request-serial', async (req, res) => {
|
|
182
|
+
const { baudRate = 115200, dataBits = 8, stopBits = 1, parity = 'none', description } = req.body;
|
|
183
|
+
const requestId = uuidv4();
|
|
184
|
+
try {
|
|
185
|
+
const { devicePath, controlSignalsSupported, readyPromise } = await serialManager.createRequest(requestId, {
|
|
186
|
+
baudRate, dataBits, stopBits, parity, description,
|
|
187
|
+
});
|
|
188
|
+
// Broadcast to all browsers so they can show the serial connect modal
|
|
189
|
+
const msg = JSON.stringify({
|
|
190
|
+
type: 'serial_request', requestId, baudRate, dataBits, stopBits, parity, description, controlSignalsSupported,
|
|
191
|
+
});
|
|
192
|
+
for (const ws of store.statusClients) {
|
|
193
|
+
if (ws.readyState === 1)
|
|
194
|
+
ws.send(msg);
|
|
195
|
+
}
|
|
196
|
+
// Wait for browser to connect (30s timeout)
|
|
197
|
+
const timeout = setTimeout(() => {
|
|
198
|
+
serialManager.rejectRequest(requestId, new Error('No browser connected within 30s'));
|
|
199
|
+
}, 30000);
|
|
200
|
+
const result = await readyPromise;
|
|
201
|
+
clearTimeout(timeout);
|
|
202
|
+
res.json({ success: true, ptyPath: result.devicePath, requestId, controlSignalsSupported: result.controlSignalsSupported });
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
serialManager.closeRequest(requestId);
|
|
206
|
+
res.status(500).json({ error: err.message });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
// Reject a serial request (user closed modal without connecting)
|
|
210
|
+
// MCP callback: suggest switching to another session
|
|
211
|
+
app.post('/api/mcp/suggest-session-switch', async (req, res) => {
|
|
212
|
+
const { session_uuid, refined_prompt, cwd, source_session_id } = req.body;
|
|
213
|
+
if (!session_uuid || !refined_prompt) {
|
|
214
|
+
return res.status(400).json({ error: 'session_uuid and refined_prompt are required' });
|
|
215
|
+
}
|
|
216
|
+
const requestId = uuidv4();
|
|
217
|
+
// Resolve chain head: find most recent session in the chain
|
|
218
|
+
let targetUuid = session_uuid;
|
|
219
|
+
try {
|
|
220
|
+
const allFiles = await agentRegistry.getSessionFilesForProject(cwd || process.cwd());
|
|
221
|
+
const allUuids = allFiles.map(f => {
|
|
222
|
+
const basename = path.basename(f, '.jsonl');
|
|
223
|
+
const m = basename.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/);
|
|
224
|
+
return m ? m[1] : '';
|
|
225
|
+
}).filter(Boolean);
|
|
226
|
+
targetUuid = resolveChainHead(session_uuid, allUuids);
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
console.warn('[mcp] Failed to resolve chain head, using provided UUID:', err);
|
|
230
|
+
}
|
|
231
|
+
// Broadcast to all browsers
|
|
232
|
+
const msg = JSON.stringify({
|
|
233
|
+
type: 'session_switch_request',
|
|
234
|
+
requestId,
|
|
235
|
+
targetSessionUuid: targetUuid,
|
|
236
|
+
originalSessionUuid: session_uuid,
|
|
237
|
+
refinedPrompt: refined_prompt,
|
|
238
|
+
sourceSessionId: source_session_id || null,
|
|
239
|
+
});
|
|
240
|
+
for (const ws of store.statusClients) {
|
|
241
|
+
if (ws.readyState === 1)
|
|
242
|
+
ws.send(msg);
|
|
243
|
+
}
|
|
244
|
+
// Block until user responds (60s timeout)
|
|
245
|
+
try {
|
|
246
|
+
const result = await new Promise((resolve) => {
|
|
247
|
+
const timeout = setTimeout(() => {
|
|
248
|
+
pendingSessionSwitches.delete(requestId);
|
|
249
|
+
resolve({ action: 'rejected' });
|
|
250
|
+
}, 60000);
|
|
251
|
+
pendingSessionSwitches.set(requestId, { resolve, timeout });
|
|
252
|
+
});
|
|
253
|
+
res.json({ action: result.action, targetSessionUuid: targetUuid });
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
res.status(500).json({ error: err.message });
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
// MCP callback: open a file in the user's browser (with confirmation)
|
|
260
|
+
app.post('/api/mcp/open-file', async (req, res) => {
|
|
261
|
+
const { file_path } = req.body;
|
|
262
|
+
if (!file_path || typeof file_path !== 'string') {
|
|
263
|
+
return res.status(400).json({ error: 'file_path is required' });
|
|
264
|
+
}
|
|
265
|
+
const requestId = uuidv4();
|
|
266
|
+
// Broadcast to all browsers
|
|
267
|
+
const msg = JSON.stringify({ type: 'open_file_request', requestId, filePath: file_path });
|
|
268
|
+
for (const ws of store.statusClients) {
|
|
269
|
+
if (ws.readyState === 1)
|
|
270
|
+
ws.send(msg);
|
|
271
|
+
}
|
|
272
|
+
// Block until user responds (30s timeout)
|
|
273
|
+
try {
|
|
274
|
+
const result = await new Promise((resolve) => {
|
|
275
|
+
const timeout = setTimeout(() => {
|
|
276
|
+
pendingOpenFiles.delete(requestId);
|
|
277
|
+
resolve({ action: 'rejected' });
|
|
278
|
+
}, 30000);
|
|
279
|
+
pendingOpenFiles.set(requestId, { resolve, timeout });
|
|
280
|
+
});
|
|
281
|
+
res.json({ action: result.action });
|
|
282
|
+
}
|
|
283
|
+
catch (err) {
|
|
284
|
+
res.status(500).json({ error: err.message });
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
// Helper: resolve chain UUIDs from a session UUID and project cwd
|
|
288
|
+
async function resolveChainUuids(sessionUuid, cwd) {
|
|
289
|
+
const allFiles = await agentRegistry.getSessionFilesForProject(cwd);
|
|
290
|
+
const allUuids = allFiles.map(f => {
|
|
291
|
+
const basename = path.basename(f, '.jsonl');
|
|
292
|
+
const m = basename.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/);
|
|
293
|
+
return m ? m[1] : '';
|
|
294
|
+
}).filter(Boolean);
|
|
295
|
+
return walkChain(sessionUuid, allUuids);
|
|
296
|
+
}
|
|
297
|
+
// Helper: resolve a session_uuid that might be an agent_xxx ID to its CLI UUID
|
|
298
|
+
function resolveToCliUuid(sessionUuid) {
|
|
299
|
+
// If it's an agent session ID, look up its CLI UUID
|
|
300
|
+
const agent = agentRegistry.getAgent(sessionUuid);
|
|
301
|
+
if (agent) {
|
|
302
|
+
const cliId = agent.getCliSessionId?.();
|
|
303
|
+
if (cliId)
|
|
304
|
+
return cliId;
|
|
305
|
+
}
|
|
306
|
+
return sessionUuid;
|
|
307
|
+
}
|
|
308
|
+
// Helper: find active agent session IDs whose CLI UUID is in the given set
|
|
309
|
+
function findAgentSessionIds(chainUuids) {
|
|
310
|
+
const uuidSet = new Set(chainUuids);
|
|
311
|
+
const agentIds = [];
|
|
312
|
+
for (const info of agentRegistry.listAgents()) {
|
|
313
|
+
const agent = agentRegistry.getAgent(info.sessionId);
|
|
314
|
+
if (agent) {
|
|
315
|
+
const cliId = agent.getCliSessionId?.();
|
|
316
|
+
if (cliId && uuidSet.has(cliId)) {
|
|
317
|
+
agentIds.push(info.sessionId);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return agentIds;
|
|
322
|
+
}
|
|
323
|
+
// Helper: merge metadata across a chain (name/description from latest, tags deduplicated)
|
|
324
|
+
function mergeChainMetadata(chainUuids) {
|
|
325
|
+
const allMeta = db.getMetadataForSessions(chainUuids);
|
|
326
|
+
const allTags = new Set();
|
|
327
|
+
let name;
|
|
328
|
+
let description;
|
|
329
|
+
// Walk from newest to oldest for name/description (first found wins)
|
|
330
|
+
for (let i = chainUuids.length - 1; i >= 0; i--) {
|
|
331
|
+
const meta = allMeta[chainUuids[i]];
|
|
332
|
+
if (!meta)
|
|
333
|
+
continue;
|
|
334
|
+
if (!name && meta.name)
|
|
335
|
+
name = meta.name;
|
|
336
|
+
if (!description && meta.description)
|
|
337
|
+
description = meta.description;
|
|
338
|
+
for (const t of meta.tags)
|
|
339
|
+
allTags.add(t);
|
|
340
|
+
}
|
|
341
|
+
return { name, description, tags: Array.from(allTags) };
|
|
342
|
+
}
|
|
343
|
+
// MCP callback: get metadata for a session chain
|
|
344
|
+
app.post('/api/mcp/get-metadata', async (req, res) => {
|
|
345
|
+
const { session_uuid, cwd } = req.body;
|
|
346
|
+
if (!session_uuid) {
|
|
347
|
+
return res.status(400).json({ error: 'session_uuid is required' });
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
const cliUuid = resolveToCliUuid(session_uuid);
|
|
351
|
+
const chainUuids = await resolveChainUuids(cliUuid, cwd || process.cwd());
|
|
352
|
+
const agentIds = findAgentSessionIds(chainUuids);
|
|
353
|
+
const merged = mergeChainMetadata(chainUuids);
|
|
354
|
+
res.json({ ...merged, chainSessionIds: [...chainUuids, ...agentIds] });
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
res.status(500).json({ error: err.message });
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
// MCP callback: set metadata on a session
|
|
361
|
+
app.post('/api/mcp/set-metadata', async (req, res) => {
|
|
362
|
+
const { session_uuid, name, description, tags, cwd } = req.body;
|
|
363
|
+
if (!session_uuid) {
|
|
364
|
+
return res.status(400).json({ error: 'session_uuid is required' });
|
|
365
|
+
}
|
|
366
|
+
if (name === undefined && description === undefined && tags === undefined) {
|
|
367
|
+
return res.status(400).json({ error: 'At least one of name, description, or tags is required' });
|
|
368
|
+
}
|
|
369
|
+
try {
|
|
370
|
+
db.setSessionMetadata(session_uuid, { name, description, tags });
|
|
371
|
+
const chainUuids = await resolveChainUuids(session_uuid, cwd || process.cwd());
|
|
372
|
+
const agentIds = findAgentSessionIds(chainUuids);
|
|
373
|
+
const merged = mergeChainMetadata(chainUuids);
|
|
374
|
+
// Broadcast to all browsers — include both CLI UUIDs and agent_xxx IDs
|
|
375
|
+
const msg = JSON.stringify({
|
|
376
|
+
type: 'session_metadata_updated',
|
|
377
|
+
sessionUuids: [...chainUuids, ...agentIds],
|
|
378
|
+
...merged,
|
|
379
|
+
});
|
|
380
|
+
for (const ws of store.statusClients) {
|
|
381
|
+
if (ws.readyState === 1)
|
|
382
|
+
ws.send(msg);
|
|
383
|
+
}
|
|
384
|
+
res.json({ success: true, ...merged });
|
|
385
|
+
}
|
|
386
|
+
catch (err) {
|
|
387
|
+
res.status(500).json({ error: err.message });
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
// MCP callback: search session history files for the current project
|
|
391
|
+
app.post('/api/mcp/search-history', async (req, res) => {
|
|
392
|
+
const { query, max_results, max_results_per_query, cwd, type, session_uuid, sort } = req.body;
|
|
393
|
+
if (!query) {
|
|
394
|
+
return res.status(400).json({ error: 'query is required' });
|
|
395
|
+
}
|
|
396
|
+
if (!cwd || typeof cwd !== 'string') {
|
|
397
|
+
return res.status(400).json({ error: 'cwd is required' });
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
// Support both old (max_results) and new (max_results_per_query) parameter names
|
|
401
|
+
const limit = max_results_per_query ?? max_results ?? 50;
|
|
402
|
+
const results = await searchSessionHistory(query, cwd, limit, {
|
|
403
|
+
type: type || 'FullProjectSearch',
|
|
404
|
+
sessionUuid: session_uuid,
|
|
405
|
+
sort: sort || 'newest_first',
|
|
406
|
+
});
|
|
407
|
+
res.json(results);
|
|
408
|
+
}
|
|
409
|
+
catch (err) {
|
|
410
|
+
res.status(500).json({ error: err.message });
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
// MCP callback: fetch full messages from a session by range
|
|
414
|
+
app.post('/api/mcp/fetch-history-range', async (req, res) => {
|
|
415
|
+
const { cwd, type, session_uuid, sort, session, target_session_uuid, from, to } = req.body;
|
|
416
|
+
if (!cwd || typeof cwd !== 'string') {
|
|
417
|
+
return res.status(400).json({ error: 'cwd is required' });
|
|
418
|
+
}
|
|
419
|
+
if (session == null && !target_session_uuid) {
|
|
420
|
+
return res.status(400).json({ error: 'session or target_session_uuid is required' });
|
|
421
|
+
}
|
|
422
|
+
if (from == null || to == null) {
|
|
423
|
+
return res.status(400).json({ error: 'from and to are required' });
|
|
424
|
+
}
|
|
425
|
+
try {
|
|
426
|
+
const result = await fetchSessionRange(cwd, {
|
|
427
|
+
type: type || 'FullProjectSearch',
|
|
428
|
+
sessionUuid: session_uuid,
|
|
429
|
+
sort: sort || 'newest_first',
|
|
430
|
+
session,
|
|
431
|
+
targetSessionUuid: target_session_uuid,
|
|
432
|
+
from,
|
|
433
|
+
to,
|
|
434
|
+
});
|
|
435
|
+
res.json(result);
|
|
436
|
+
}
|
|
437
|
+
catch (err) {
|
|
438
|
+
res.status(500).json({ error: err.message });
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
// MCP callback: database connect/query/schema (must be before auth wall)
|
|
442
|
+
app.post('/api/mcp/connect-database', handleMcpConnectDatabase);
|
|
443
|
+
// MCP callback: track project changes ("what has been done")
|
|
444
|
+
app.post('/api/mcp/track-changes', async (req, res) => {
|
|
445
|
+
const { mode, id, short_description, long_description, tags, approx_files_affected, session_uuid, cwd } = req.body;
|
|
446
|
+
if (!mode)
|
|
447
|
+
return res.status(400).json({ error: 'mode is required' });
|
|
448
|
+
// Resolve project from cwd
|
|
449
|
+
const projectPath = cwd || process.cwd();
|
|
450
|
+
const project = db.findProjectByPath(projectPath);
|
|
451
|
+
if (!project)
|
|
452
|
+
return res.status(404).json({ error: `No project found for path: ${projectPath}` });
|
|
453
|
+
try {
|
|
454
|
+
if (mode === 'get') {
|
|
455
|
+
const changes = db.listProjectChanges(project.id);
|
|
456
|
+
return res.json({ changes });
|
|
457
|
+
}
|
|
458
|
+
else if (mode === 'set') {
|
|
459
|
+
if (!short_description)
|
|
460
|
+
return res.status(400).json({ error: 'short_description is required for set mode' });
|
|
461
|
+
if (approx_files_affected === undefined)
|
|
462
|
+
return res.status(400).json({ error: 'approx_files_affected is required for set mode' });
|
|
463
|
+
let change;
|
|
464
|
+
if (id) {
|
|
465
|
+
// Update existing
|
|
466
|
+
const existing = db.getProjectChange(id);
|
|
467
|
+
if (!existing)
|
|
468
|
+
return res.status(404).json({ error: 'Change entry not found' });
|
|
469
|
+
db.updateProjectChange(id, short_description, long_description || '', tags || [], approx_files_affected);
|
|
470
|
+
change = db.getProjectChange(id);
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
// Create new
|
|
474
|
+
change = db.createProjectChange(project.id, short_description, long_description || '', tags || [], approx_files_affected, session_uuid || null);
|
|
475
|
+
}
|
|
476
|
+
// Broadcast to UI
|
|
477
|
+
const msg = JSON.stringify({ type: 'project_changes_updated', projectId: project.id });
|
|
478
|
+
for (const ws of store.statusClients) {
|
|
479
|
+
if (ws.readyState === 1)
|
|
480
|
+
ws.send(msg);
|
|
481
|
+
}
|
|
482
|
+
return res.json({ change });
|
|
483
|
+
}
|
|
484
|
+
else if (mode === 'delete') {
|
|
485
|
+
if (!id)
|
|
486
|
+
return res.status(400).json({ error: 'id is required for delete mode' });
|
|
487
|
+
const deleted = db.deleteProjectChange(id);
|
|
488
|
+
if (!deleted)
|
|
489
|
+
return res.status(404).json({ error: 'Change entry not found' });
|
|
490
|
+
// Broadcast to UI
|
|
491
|
+
const msg = JSON.stringify({ type: 'project_changes_updated', projectId: project.id });
|
|
492
|
+
for (const ws of store.statusClients) {
|
|
493
|
+
if (ws.readyState === 1)
|
|
494
|
+
ws.send(msg);
|
|
495
|
+
}
|
|
496
|
+
return res.json({ message: 'Change deleted' });
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
return res.status(400).json({ error: `Unknown mode: ${mode}` });
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
catch (err) {
|
|
503
|
+
res.status(500).json({ error: err.message });
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
// ═══════════════════════════════════════════════════════
|
|
507
|
+
// Auth middleware — protects all routes below this point
|
|
508
|
+
// Routes above (CA certs, auth, MCP callbacks) are exempt
|
|
509
|
+
// ═══════════════════════════════════════════════════════
|
|
510
|
+
app.use('/api', requireAuth);
|
|
511
|
+
// List CLI environments (runtime) — no longer applicable without MITM proxy
|
|
512
|
+
// ── Terminal file attachment ──────────────────────────────────────────
|
|
513
|
+
// Receives a file (base64) from the browser, writes it to a temp dir,
|
|
514
|
+
// returns the path so the frontend can inject it into the PTY.
|
|
515
|
+
const ATTACHMENT_BASE = path.join(os.tmpdir(), 'atoo');
|
|
516
|
+
app.post('/api/terminal/attachment', requireAuth, (req, res) => {
|
|
517
|
+
try {
|
|
518
|
+
const { filename, data } = req.body;
|
|
519
|
+
if (!filename || !data) {
|
|
520
|
+
res.status(400).json({ error: 'filename and data (base64) are required' });
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
// Sanitize filename: strip path separators, limit length
|
|
524
|
+
const safeName = path.basename(filename).slice(0, 200) || 'attachment';
|
|
525
|
+
const dirId = `attachment_${uuidv4()}`;
|
|
526
|
+
const dir = path.join(ATTACHMENT_BASE, dirId);
|
|
527
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
528
|
+
const filePath = path.join(dir, safeName);
|
|
529
|
+
fs.writeFileSync(filePath, Buffer.from(data, 'base64'));
|
|
530
|
+
res.json({ path: filePath });
|
|
531
|
+
}
|
|
532
|
+
catch (err) {
|
|
533
|
+
console.error('[attachment] Failed to write:', err);
|
|
534
|
+
res.status(500).json({ error: err.message });
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
// Cleanup old attachments on startup (older than 1 hour)
|
|
538
|
+
try {
|
|
539
|
+
if (fs.existsSync(ATTACHMENT_BASE)) {
|
|
540
|
+
const cutoff = Date.now() - 3600_000;
|
|
541
|
+
for (const entry of fs.readdirSync(ATTACHMENT_BASE)) {
|
|
542
|
+
const full = path.join(ATTACHMENT_BASE, entry);
|
|
543
|
+
try {
|
|
544
|
+
if (fs.statSync(full).mtimeMs < cutoff)
|
|
545
|
+
fs.rmSync(full, { recursive: true, force: true });
|
|
546
|
+
}
|
|
547
|
+
catch { }
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
catch { }
|
|
552
|
+
app.get('/api/cli-environments', (_req, res) => {
|
|
553
|
+
res.json([]);
|
|
554
|
+
});
|
|
555
|
+
// Browse directories for folder picker
|
|
556
|
+
app.get('/api/browse', (req, res) => {
|
|
557
|
+
const dir = req.query.path || os.homedir();
|
|
558
|
+
try {
|
|
559
|
+
const resolved = path.resolve(dir);
|
|
560
|
+
const entries = fs.readdirSync(resolved, { withFileTypes: true });
|
|
561
|
+
const dirs = entries
|
|
562
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
563
|
+
.map((e) => ({
|
|
564
|
+
name: e.name,
|
|
565
|
+
path: path.join(resolved, e.name),
|
|
566
|
+
}))
|
|
567
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
568
|
+
res.json({ current: resolved, parent: path.dirname(resolved), dirs });
|
|
569
|
+
}
|
|
570
|
+
catch (err) {
|
|
571
|
+
res.status(400).json({ error: err.message });
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
app.post('/api/browse/mkdir', (req, res) => {
|
|
575
|
+
const { path: dirPath } = req.body;
|
|
576
|
+
if (!dirPath)
|
|
577
|
+
return res.status(400).json({ error: 'path is required' });
|
|
578
|
+
try {
|
|
579
|
+
const resolved = path.resolve(dirPath);
|
|
580
|
+
fs.mkdirSync(resolved, { recursive: true });
|
|
581
|
+
res.json({ success: true, path: resolved });
|
|
582
|
+
}
|
|
583
|
+
catch (err) {
|
|
584
|
+
res.status(400).json({ error: err.message });
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
// Extract text from office documents (docx, xlsx, pptx)
|
|
588
|
+
app.post('/api/extract-text', async (req, res) => {
|
|
589
|
+
const { data, name } = req.body;
|
|
590
|
+
if (!data || !name)
|
|
591
|
+
return res.status(400).json({ error: 'data and name are required' });
|
|
592
|
+
const ext = name.split('.').pop()?.toLowerCase();
|
|
593
|
+
const buf = Buffer.from(data, 'base64');
|
|
594
|
+
try {
|
|
595
|
+
let text = '';
|
|
596
|
+
if (ext === 'docx') {
|
|
597
|
+
const mammoth = await import('mammoth');
|
|
598
|
+
const result = await mammoth.default.extractRawText({ buffer: buf });
|
|
599
|
+
text = result.value;
|
|
600
|
+
}
|
|
601
|
+
else if (ext === 'xlsx' || ext === 'xls') {
|
|
602
|
+
const ExcelJS = await import('exceljs');
|
|
603
|
+
const workbook = new ExcelJS.default.Workbook();
|
|
604
|
+
await workbook.xlsx.load(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
|
|
605
|
+
const sheets = [];
|
|
606
|
+
workbook.eachSheet((sheet) => {
|
|
607
|
+
const rows = [];
|
|
608
|
+
sheet.eachRow((row) => {
|
|
609
|
+
const values = row.values.slice(1); // ExcelJS row.values is 1-indexed
|
|
610
|
+
rows.push(values.map(v => v ?? '').join(','));
|
|
611
|
+
});
|
|
612
|
+
sheets.push(`--- Sheet: ${sheet.name} ---\n${rows.join('\n')}`);
|
|
613
|
+
});
|
|
614
|
+
text = sheets.join('\n\n');
|
|
615
|
+
}
|
|
616
|
+
else if (ext === 'pptx') {
|
|
617
|
+
// pptx is a zip of XML slides — extract text from slide XML files
|
|
618
|
+
const { Readable } = await import('stream');
|
|
619
|
+
const unzipper = await import('unzipper');
|
|
620
|
+
const slides = [];
|
|
621
|
+
const stream = Readable.from(buf);
|
|
622
|
+
const directory = await stream.pipe(unzipper.default.Parse());
|
|
623
|
+
for await (const entry of directory) {
|
|
624
|
+
const entryPath = entry.path;
|
|
625
|
+
if (entryPath.match(/^ppt\/slides\/slide\d+\.xml$/)) {
|
|
626
|
+
const xml = (await entry.buffer()).toString('utf-8');
|
|
627
|
+
// Extract text from <a:t> tags
|
|
628
|
+
const texts = [];
|
|
629
|
+
const re = /<a:t[^>]*>([\s\S]*?)<\/a:t>/g;
|
|
630
|
+
let m;
|
|
631
|
+
while ((m = re.exec(xml)) !== null)
|
|
632
|
+
texts.push(m[1]);
|
|
633
|
+
const slideNum = parseInt(entryPath.match(/slide(\d+)/)?.[1] || '0');
|
|
634
|
+
if (texts.length)
|
|
635
|
+
slides.push({ num: slideNum, text: texts.join(' ') });
|
|
636
|
+
}
|
|
637
|
+
else {
|
|
638
|
+
entry.autodrain();
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
slides.sort((a, b) => a.num - b.num);
|
|
642
|
+
text = slides.map(s => `--- Slide ${s.num} ---\n${s.text}`).join('\n\n');
|
|
643
|
+
}
|
|
644
|
+
else {
|
|
645
|
+
return res.status(400).json({ error: `Unsupported file type: .${ext}` });
|
|
646
|
+
}
|
|
647
|
+
res.json({ text });
|
|
648
|
+
}
|
|
649
|
+
catch (err) {
|
|
650
|
+
console.error(`[web] extract-text error for ${name}:`, err.message);
|
|
651
|
+
res.status(500).json({ error: err.message });
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
// Broadcast dismiss_modal to all connected browsers so they close stale popups
|
|
655
|
+
function broadcastDismissModal(requestId) {
|
|
656
|
+
const msg = JSON.stringify({ type: 'dismiss_modal', requestId });
|
|
657
|
+
for (const ws of store.statusClients) {
|
|
658
|
+
if (ws.readyState === 1)
|
|
659
|
+
ws.send(msg);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
// Frontend callbacks for MCP-initiated user prompts (serial, session switch, open file)
|
|
663
|
+
app.post('/api/reject-serial', (req, res) => {
|
|
664
|
+
const { requestId } = req.body;
|
|
665
|
+
if (!requestId)
|
|
666
|
+
return res.status(400).json({ error: 'requestId required' });
|
|
667
|
+
serialManager.rejectRequest(requestId, new Error('User rejected the serial device request'));
|
|
668
|
+
serialManager.closeRequest(requestId);
|
|
669
|
+
broadcastDismissModal(requestId);
|
|
670
|
+
res.json({ success: true });
|
|
671
|
+
});
|
|
672
|
+
app.post('/api/respond-session-switch', (req, res) => {
|
|
673
|
+
const { requestId, action } = req.body;
|
|
674
|
+
if (!requestId || !action) {
|
|
675
|
+
return res.status(400).json({ error: 'requestId and action are required' });
|
|
676
|
+
}
|
|
677
|
+
const pending = pendingSessionSwitches.get(requestId);
|
|
678
|
+
if (!pending) {
|
|
679
|
+
return res.status(404).json({ error: 'No pending request with this ID' });
|
|
680
|
+
}
|
|
681
|
+
clearTimeout(pending.timeout);
|
|
682
|
+
pendingSessionSwitches.delete(requestId);
|
|
683
|
+
pending.resolve({ action });
|
|
684
|
+
broadcastDismissModal(requestId);
|
|
685
|
+
res.json({ success: true });
|
|
686
|
+
});
|
|
687
|
+
app.post('/api/respond-open-file', (req, res) => {
|
|
688
|
+
const { requestId, action } = req.body;
|
|
689
|
+
if (!requestId || !action) {
|
|
690
|
+
return res.status(400).json({ error: 'requestId and action are required' });
|
|
691
|
+
}
|
|
692
|
+
const pending = pendingOpenFiles.get(requestId);
|
|
693
|
+
if (!pending) {
|
|
694
|
+
return res.status(404).json({ error: 'No pending request with this ID' });
|
|
695
|
+
}
|
|
696
|
+
clearTimeout(pending.timeout);
|
|
697
|
+
pendingOpenFiles.delete(requestId);
|
|
698
|
+
pending.resolve({ action });
|
|
699
|
+
broadcastDismissModal(requestId);
|
|
700
|
+
res.json({ success: true });
|
|
701
|
+
});
|
|
702
|
+
// Mount changes API routes
|
|
703
|
+
app.use(changesRouter);
|
|
704
|
+
// Project changes ("what has been done") — REST API for frontend
|
|
705
|
+
app.get('/api/projects/:id/changes', (req, res) => {
|
|
706
|
+
const changes = db.listProjectChanges(req.params.id);
|
|
707
|
+
res.json({ changes });
|
|
708
|
+
});
|
|
709
|
+
app.delete('/api/projects/:id/changes/:changeId', (req, res) => {
|
|
710
|
+
const deleted = db.deleteProjectChange(req.params.changeId);
|
|
711
|
+
if (!deleted)
|
|
712
|
+
return res.status(404).json({ error: 'Not found' });
|
|
713
|
+
res.json({ success: true });
|
|
714
|
+
});
|
|
715
|
+
app.delete('/api/projects/:id/changes', (req, res) => {
|
|
716
|
+
const count = db.deleteAllProjectChanges(req.params.id);
|
|
717
|
+
res.json({ deleted: count });
|
|
718
|
+
});
|
|
719
|
+
// Mount project/file/git API routes
|
|
720
|
+
app.use(projectsRouter);
|
|
721
|
+
// Mount environment API routes
|
|
722
|
+
app.use(environmentsRouter);
|
|
723
|
+
// Mount SSH API routes
|
|
724
|
+
app.use(sshRouter);
|
|
725
|
+
// Mount GitHub integration API routes
|
|
726
|
+
app.use(githubRouter);
|
|
727
|
+
// Mount container management API routes
|
|
728
|
+
app.use(containersRouter);
|
|
729
|
+
// Mount database explorer API routes
|
|
730
|
+
app.use(databasesRouter);
|
|
731
|
+
// Mount DevTools proxy
|
|
732
|
+
app.use(devtoolsProxyMiddleware());
|
|
733
|
+
// Preview: upload files for file chooser interception
|
|
734
|
+
app.post('/api/preview/:projectId/:tabId/upload', (req, res) => {
|
|
735
|
+
const { projectId, tabId } = req.params;
|
|
736
|
+
const { files, backendNodeId } = req.body;
|
|
737
|
+
// files = [{ name: string, data: string (base64) }]
|
|
738
|
+
if (!Array.isArray(files) || !backendNodeId) {
|
|
739
|
+
return res.status(400).json({ error: 'files array and backendNodeId required' });
|
|
740
|
+
}
|
|
741
|
+
const instance = previewManager.get(decodeURIComponent(projectId), decodeURIComponent(tabId));
|
|
742
|
+
if (!instance)
|
|
743
|
+
return res.status(404).json({ error: 'Preview instance not found' });
|
|
744
|
+
const tmpDir = path.join(os.tmpdir(), 'atoo-studio-uploads', `${projectId}_${tabId}`);
|
|
745
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
746
|
+
const filePaths = [];
|
|
747
|
+
for (const f of files) {
|
|
748
|
+
const filePath = path.join(tmpDir, f.name);
|
|
749
|
+
fs.writeFileSync(filePath, Buffer.from(f.data, 'base64'));
|
|
750
|
+
filePaths.push(filePath);
|
|
751
|
+
}
|
|
752
|
+
previewManager.handleFileChooserResponse(decodeURIComponent(projectId), decodeURIComponent(tabId), backendNodeId, filePaths).then(() => res.json({ success: true }))
|
|
753
|
+
.catch((err) => res.status(500).json({ error: err.message }));
|
|
754
|
+
});
|
|
755
|
+
// Preview: download intercepted file
|
|
756
|
+
app.get('/api/preview/:projectId/:tabId/download/:guid', (req, res) => {
|
|
757
|
+
const { projectId, tabId, guid } = req.params;
|
|
758
|
+
const filePath = previewManager.getDownloadPath(decodeURIComponent(projectId), decodeURIComponent(tabId), guid);
|
|
759
|
+
if (!filePath)
|
|
760
|
+
return res.status(404).json({ error: 'Download not found' });
|
|
761
|
+
res.download(filePath);
|
|
762
|
+
});
|
|
763
|
+
// List running shell terminals
|
|
764
|
+
app.get('/api/terminals', (_req, res) => {
|
|
765
|
+
const terminals = [];
|
|
766
|
+
for (const [id, entry] of shellTerminals) {
|
|
767
|
+
terminals.push({ id, cwd: entry.cwd, projectPath: entry.projectPath, pid: entry.pty.pid });
|
|
768
|
+
}
|
|
769
|
+
res.json(terminals);
|
|
770
|
+
});
|
|
771
|
+
// Spawn a standalone shell terminal
|
|
772
|
+
app.post('/api/terminals', (req, res) => {
|
|
773
|
+
const { cwd } = req.body || {};
|
|
774
|
+
const shell = process.env.SHELL || '/bin/bash';
|
|
775
|
+
const termCwd = cwd || os.homedir();
|
|
776
|
+
const id = uuidv4();
|
|
777
|
+
try {
|
|
778
|
+
const term = pty.spawn(shell, [], {
|
|
779
|
+
name: 'xterm-256color',
|
|
780
|
+
cols: 120,
|
|
781
|
+
rows: 30,
|
|
782
|
+
cwd: termCwd,
|
|
783
|
+
env: { ...process.env, TERM: 'xterm-256color' },
|
|
784
|
+
});
|
|
785
|
+
shellTerminals.set(id, { pty: term, cwd: termCwd, projectPath: termCwd });
|
|
786
|
+
// Broadcast terminal_created to all status clients (cross-browser sync)
|
|
787
|
+
const createdMsg = JSON.stringify({ type: 'terminal_created', terminal: { id, cwd: termCwd, projectPath: termCwd, pid: term.pid } });
|
|
788
|
+
for (const ws of store.statusClients) {
|
|
789
|
+
if (ws.readyState === 1)
|
|
790
|
+
ws.send(createdMsg);
|
|
791
|
+
}
|
|
792
|
+
term.onExit(() => {
|
|
793
|
+
shellTerminals.delete(id);
|
|
794
|
+
// Broadcast terminal_exited to all status clients
|
|
795
|
+
const exitMsg = JSON.stringify({ type: 'terminal_exited', terminal: { id } });
|
|
796
|
+
for (const ws of store.statusClients) {
|
|
797
|
+
if (ws.readyState === 1)
|
|
798
|
+
ws.send(exitMsg);
|
|
799
|
+
}
|
|
800
|
+
});
|
|
801
|
+
console.log(`[shell] Spawned standalone terminal ${id} (PID ${term.pid}) in ${termCwd}`);
|
|
802
|
+
res.json({ id, pid: term.pid });
|
|
803
|
+
}
|
|
804
|
+
catch (err) {
|
|
805
|
+
res.status(500).json({ error: err.message });
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
// Kill a shell terminal
|
|
809
|
+
app.delete('/api/terminals/:id', (req, res) => {
|
|
810
|
+
const entry = shellTerminals.get(req.params.id);
|
|
811
|
+
if (!entry)
|
|
812
|
+
return res.status(404).json({ error: 'Terminal not found' });
|
|
813
|
+
entry.pty.kill();
|
|
814
|
+
res.json({ success: true });
|
|
815
|
+
});
|
|
816
|
+
// Server LAN IP for nip.io reverse proxy URLs
|
|
817
|
+
app.get('/api/server-ip', (req, res) => {
|
|
818
|
+
// If the client connected via IP already, return that
|
|
819
|
+
const host = req.hostname;
|
|
820
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
|
|
821
|
+
return res.json({ ip: host });
|
|
822
|
+
}
|
|
823
|
+
// Otherwise find first non-internal IPv4 address
|
|
824
|
+
const nets = os.networkInterfaces();
|
|
825
|
+
for (const ifaces of Object.values(nets)) {
|
|
826
|
+
for (const iface of ifaces || []) {
|
|
827
|
+
if (iface.family === 'IPv4' && !iface.internal) {
|
|
828
|
+
return res.json({ ip: iface.address });
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
res.json({ ip: '127.0.0.1' });
|
|
833
|
+
});
|
|
834
|
+
// Status
|
|
835
|
+
app.get('/api/status', (_req, res) => {
|
|
836
|
+
res.json({
|
|
837
|
+
sessions: store.sessions.size,
|
|
838
|
+
});
|
|
839
|
+
});
|
|
840
|
+
// Dependencies check
|
|
841
|
+
app.get('/api/dependencies', (_req, res) => {
|
|
842
|
+
function getVersion(cmd) {
|
|
843
|
+
try {
|
|
844
|
+
return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).trim();
|
|
845
|
+
}
|
|
846
|
+
catch {
|
|
847
|
+
return null;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
function hasCommand(cmd) {
|
|
851
|
+
try {
|
|
852
|
+
execSync(`which ${cmd}`, { stdio: 'ignore', timeout: 3000 });
|
|
853
|
+
return true;
|
|
854
|
+
}
|
|
855
|
+
catch {
|
|
856
|
+
return false;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
const platform = process.platform;
|
|
860
|
+
const deps = [];
|
|
861
|
+
// Required
|
|
862
|
+
deps.push({
|
|
863
|
+
name: 'git',
|
|
864
|
+
description: 'Version control, worktrees, file tracking',
|
|
865
|
+
installed: hasCommand('git'),
|
|
866
|
+
version: getVersion('git --version'),
|
|
867
|
+
required: true,
|
|
868
|
+
category: 'Core',
|
|
869
|
+
});
|
|
870
|
+
// Agent CLIs
|
|
871
|
+
deps.push({
|
|
872
|
+
name: 'claude',
|
|
873
|
+
description: 'Claude Code agent',
|
|
874
|
+
installed: hasCommand('claude'),
|
|
875
|
+
version: getVersion('claude --version'),
|
|
876
|
+
required: false,
|
|
877
|
+
category: 'Agents',
|
|
878
|
+
});
|
|
879
|
+
deps.push({
|
|
880
|
+
name: 'codex',
|
|
881
|
+
description: 'Codex agent',
|
|
882
|
+
installed: hasCommand('codex'),
|
|
883
|
+
version: getVersion('codex --version'),
|
|
884
|
+
required: false,
|
|
885
|
+
category: 'Agents',
|
|
886
|
+
});
|
|
887
|
+
// Integrations
|
|
888
|
+
deps.push({
|
|
889
|
+
name: 'gh',
|
|
890
|
+
description: 'GitHub integration (issues, PRs)',
|
|
891
|
+
installed: hasCommand('gh'),
|
|
892
|
+
version: getVersion('gh --version'),
|
|
893
|
+
required: false,
|
|
894
|
+
category: 'Integrations',
|
|
895
|
+
});
|
|
896
|
+
// Container runtimes
|
|
897
|
+
deps.push({
|
|
898
|
+
name: 'docker',
|
|
899
|
+
description: 'Docker container management',
|
|
900
|
+
installed: hasCommand('docker'),
|
|
901
|
+
version: getVersion('docker --version'),
|
|
902
|
+
required: false,
|
|
903
|
+
category: 'Containers',
|
|
904
|
+
});
|
|
905
|
+
deps.push({
|
|
906
|
+
name: 'podman',
|
|
907
|
+
description: 'Podman container management',
|
|
908
|
+
installed: hasCommand('podman'),
|
|
909
|
+
version: getVersion('podman --version'),
|
|
910
|
+
required: false,
|
|
911
|
+
category: 'Containers',
|
|
912
|
+
});
|
|
913
|
+
deps.push({
|
|
914
|
+
name: 'lxc',
|
|
915
|
+
description: 'LXC container management',
|
|
916
|
+
installed: hasCommand('lxc'),
|
|
917
|
+
version: getVersion('lxc --version'),
|
|
918
|
+
required: false,
|
|
919
|
+
category: 'Containers',
|
|
920
|
+
});
|
|
921
|
+
// Media
|
|
922
|
+
deps.push({
|
|
923
|
+
name: 'ffmpeg',
|
|
924
|
+
description: 'Screen recording',
|
|
925
|
+
installed: hasCommand('ffmpeg'),
|
|
926
|
+
version: getVersion('ffmpeg -version'),
|
|
927
|
+
required: false,
|
|
928
|
+
category: 'Media',
|
|
929
|
+
});
|
|
930
|
+
// Linux-specific
|
|
931
|
+
if (platform === 'linux') {
|
|
932
|
+
let chromeLibs = false;
|
|
933
|
+
try {
|
|
934
|
+
execSync('ldconfig -p | grep -q libatk-1.0', { stdio: 'ignore', timeout: 3000 });
|
|
935
|
+
chromeLibs = true;
|
|
936
|
+
}
|
|
937
|
+
catch { }
|
|
938
|
+
deps.push({
|
|
939
|
+
name: 'Chrome libs',
|
|
940
|
+
description: 'Browser preview (Puppeteer dependencies)',
|
|
941
|
+
installed: chromeLibs,
|
|
942
|
+
version: null,
|
|
943
|
+
required: false,
|
|
944
|
+
category: 'Preview',
|
|
945
|
+
});
|
|
946
|
+
const cusebin = path.join(process.env.HOME || '', '.atoo-studio', 'bin', 'cuse_serial');
|
|
947
|
+
const cuseReady = fs.existsSync('/dev/cuse') && fs.existsSync(cusebin);
|
|
948
|
+
deps.push({
|
|
949
|
+
name: 'CUSE',
|
|
950
|
+
description: 'Serial control signals (DTR/RTS)',
|
|
951
|
+
installed: cuseReady,
|
|
952
|
+
version: null,
|
|
953
|
+
required: false,
|
|
954
|
+
category: 'Serial',
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
res.json({ platform, deps });
|
|
958
|
+
});
|
|
959
|
+
// ═══════════════════════════════════════════════════════
|
|
960
|
+
// Update check & self-update
|
|
961
|
+
// ═══════════════════════════════════════════════════════
|
|
962
|
+
// Cache the update check so we don't hammer the registry
|
|
963
|
+
let updateCache = null;
|
|
964
|
+
const UPDATE_CACHE_TTL = 10 * 60 * 1000; // 10 minutes
|
|
965
|
+
app.get('/api/check-update', async (_req, res) => {
|
|
966
|
+
try {
|
|
967
|
+
// Return cached result if fresh
|
|
968
|
+
if (updateCache && Date.now() - updateCache.checkedAt < UPDATE_CACHE_TTL) {
|
|
969
|
+
return res.json(updateCache);
|
|
970
|
+
}
|
|
971
|
+
const pkgPath = path.join(PROJECT_ROOT, 'package.json');
|
|
972
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
973
|
+
const currentVersion = pkg.version;
|
|
974
|
+
// Fetch latest version from npm registry
|
|
975
|
+
const controller = new AbortController();
|
|
976
|
+
const timeout = setTimeout(() => controller.abort(), 8000);
|
|
977
|
+
const resp = await fetch('https://registry.npmjs.org/atoo-studio/latest', { signal: controller.signal });
|
|
978
|
+
clearTimeout(timeout);
|
|
979
|
+
if (!resp.ok)
|
|
980
|
+
throw new Error(`npm registry returned ${resp.status}`);
|
|
981
|
+
const data = await resp.json();
|
|
982
|
+
const latestVersion = data.version;
|
|
983
|
+
// Simple semver comparison: split into parts and compare numerically
|
|
984
|
+
const parseSemver = (v) => v.replace(/^v/, '').split('.').map(Number);
|
|
985
|
+
const cur = parseSemver(currentVersion);
|
|
986
|
+
const lat = parseSemver(latestVersion);
|
|
987
|
+
const updateAvailable = lat[0] > cur[0]
|
|
988
|
+
|| (lat[0] === cur[0] && lat[1] > cur[1])
|
|
989
|
+
|| (lat[0] === cur[0] && lat[1] === cur[1] && lat[2] > cur[2]);
|
|
990
|
+
updateCache = { currentVersion, latestVersion, updateAvailable, checkedAt: Date.now() };
|
|
991
|
+
res.json(updateCache);
|
|
992
|
+
}
|
|
993
|
+
catch (err) {
|
|
994
|
+
// Still return current version even if registry check fails
|
|
995
|
+
try {
|
|
996
|
+
const pkgPath = path.join(PROJECT_ROOT, 'package.json');
|
|
997
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
998
|
+
res.json({ currentVersion: pkg.version, latestVersion: null, updateAvailable: false, error: err.message });
|
|
999
|
+
}
|
|
1000
|
+
catch {
|
|
1001
|
+
res.status(500).json({ error: 'Failed to check for updates' });
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
});
|
|
1005
|
+
app.post('/api/update', async (_req, res) => {
|
|
1006
|
+
try {
|
|
1007
|
+
// Determine how atoo-studio was installed
|
|
1008
|
+
const binPath = process.argv[1] || '';
|
|
1009
|
+
const isGlobal = binPath.includes('/node_modules/.global') || binPath.includes('/lib/node_modules/');
|
|
1010
|
+
const npmCmd = isGlobal ? 'npm install -g atoo-studio@latest' : 'npx atoo-studio@latest';
|
|
1011
|
+
// Run the update
|
|
1012
|
+
const { exec } = await import('child_process');
|
|
1013
|
+
const child = exec('npm install -g atoo-studio@latest', { timeout: 120000 });
|
|
1014
|
+
let stdout = '';
|
|
1015
|
+
let stderr = '';
|
|
1016
|
+
child.stdout?.on('data', (d) => { stdout += d; });
|
|
1017
|
+
child.stderr?.on('data', (d) => { stderr += d; });
|
|
1018
|
+
child.on('close', (code) => {
|
|
1019
|
+
// Invalidate cache
|
|
1020
|
+
updateCache = null;
|
|
1021
|
+
if (code === 0) {
|
|
1022
|
+
res.json({ success: true, output: stdout.trim(), restartRequired: true });
|
|
1023
|
+
}
|
|
1024
|
+
else {
|
|
1025
|
+
res.status(500).json({ success: false, error: stderr.trim() || `Exit code ${code}` });
|
|
1026
|
+
}
|
|
1027
|
+
});
|
|
1028
|
+
child.on('error', (err) => {
|
|
1029
|
+
res.status(500).json({ success: false, error: err.message });
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
catch (err) {
|
|
1033
|
+
res.status(500).json({ success: false, error: err.message });
|
|
1034
|
+
}
|
|
1035
|
+
});
|
|
1036
|
+
// ═══════════════════════════════════════════════════════
|
|
1037
|
+
// Agent Session Endpoints (abstract layer)
|
|
1038
|
+
// ═══════════════════════════════════════════════════════
|
|
1039
|
+
// Create a new agent session
|
|
1040
|
+
app.post('/api/agent-sessions', async (req, res) => {
|
|
1041
|
+
const { agentType, cwd, skipPermissions, message, linkedIssue, resumeSessionUuid, forkParentSessionId, forkAfterEventUuid, forkFromEventUuid } = req.body;
|
|
1042
|
+
const type = agentType || 'claude-code-terminal-chatro';
|
|
1043
|
+
const sessionId = `agent_${uuidv4()}`;
|
|
1044
|
+
try {
|
|
1045
|
+
// Resolve fork: delegate to the parent agent to produce a resumable JSONL
|
|
1046
|
+
let resolvedResumeUuid = resumeSessionUuid;
|
|
1047
|
+
if (forkParentSessionId && forkAfterEventUuid) {
|
|
1048
|
+
const parentAgent = agentRegistry.getAgent(forkParentSessionId);
|
|
1049
|
+
if (!parentAgent) {
|
|
1050
|
+
return res.status(404).json({ error: `Parent agent not found: ${forkParentSessionId}` });
|
|
1051
|
+
}
|
|
1052
|
+
const resumeUuid = parentAgent.forkToResumable(forkAfterEventUuid, forkFromEventUuid, cwd || parentAgent.getInfo().cwd);
|
|
1053
|
+
if (resumeUuid) {
|
|
1054
|
+
// Fork always writes Claude JSONL — convert if target is a different family
|
|
1055
|
+
const parentAgentType = parentAgent.getInfo().agentType;
|
|
1056
|
+
resolvedResumeUuid = await agentRegistry.convertForkForAgent(resumeUuid, parentAgentType, type, cwd || parentAgent.getInfo().cwd || '.');
|
|
1057
|
+
}
|
|
1058
|
+
else {
|
|
1059
|
+
return res.status(400).json({ error: 'Agent does not support forking or fork point not found' });
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
const agent = await agentRegistry.createAgent(type, sessionId, {
|
|
1063
|
+
cwd: cwd || undefined,
|
|
1064
|
+
skipPermissions: !!skipPermissions,
|
|
1065
|
+
resumeSessionUuid: resolvedResumeUuid,
|
|
1066
|
+
initialMessage: message || undefined,
|
|
1067
|
+
});
|
|
1068
|
+
if (linkedIssue) {
|
|
1069
|
+
agentRegistry.setBrowserState(sessionId, { linkedIssue });
|
|
1070
|
+
}
|
|
1071
|
+
const info = agent.getInfo();
|
|
1072
|
+
if (linkedIssue)
|
|
1073
|
+
info.linkedIssue = linkedIssue;
|
|
1074
|
+
res.json(info);
|
|
1075
|
+
}
|
|
1076
|
+
catch (err) {
|
|
1077
|
+
console.error(`[web] Failed to create agent session:`, err.message);
|
|
1078
|
+
res.status(500).json({ error: err.message });
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
1081
|
+
// List agent sessions
|
|
1082
|
+
app.get('/api/agent-sessions', (_req, res) => {
|
|
1083
|
+
res.json(agentRegistry.listAgents());
|
|
1084
|
+
});
|
|
1085
|
+
// Update browser-side state for a session (cached in memory, not DB)
|
|
1086
|
+
app.patch('/api/agent-sessions/:id/browser-state', (req, res) => {
|
|
1087
|
+
const { id } = req.params;
|
|
1088
|
+
const state = req.body;
|
|
1089
|
+
agentRegistry.setBrowserState(id, state);
|
|
1090
|
+
res.json({ ok: true });
|
|
1091
|
+
});
|
|
1092
|
+
// Available agent types (for agent picker UI)
|
|
1093
|
+
app.get('/api/available-agents', (_req, res) => {
|
|
1094
|
+
res.json(agentRegistry.getAvailableAgents());
|
|
1095
|
+
});
|
|
1096
|
+
// Historical sessions from all agent implementations
|
|
1097
|
+
// Optional ?cwd= param to filter by project path (includes worktree-related paths)
|
|
1098
|
+
app.get('/api/historical-sessions', async (req, res) => {
|
|
1099
|
+
try {
|
|
1100
|
+
const cwd = req.query.cwd;
|
|
1101
|
+
const sessions = await agentRegistry.getHistoricalSessions(cwd);
|
|
1102
|
+
// Enrich with metadata from DB
|
|
1103
|
+
if (sessions.length) {
|
|
1104
|
+
const allMeta = db.getMetadataForSessions(sessions.map(s => s.id));
|
|
1105
|
+
for (const s of sessions) {
|
|
1106
|
+
const meta = allMeta[s.id];
|
|
1107
|
+
if (meta?.name)
|
|
1108
|
+
s.metaName = meta.name;
|
|
1109
|
+
if (meta?.tags?.length)
|
|
1110
|
+
s.tags = meta.tags;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
res.json(sessions);
|
|
1114
|
+
}
|
|
1115
|
+
catch (err) {
|
|
1116
|
+
res.status(500).json({ error: err.message });
|
|
1117
|
+
}
|
|
1118
|
+
});
|
|
1119
|
+
// Create a chain continuation from an existing session.
|
|
1120
|
+
// Accepts either:
|
|
1121
|
+
// - agentSessionId: an active agent session (agent_xxx) — reads events from live agent memory
|
|
1122
|
+
// - sessionUuid: a historical JSONL session UUID — reads events from disk
|
|
1123
|
+
app.post('/api/agent-sessions/chain', async (req, res) => {
|
|
1124
|
+
const { agentSessionId, sessionUuid, cwd, skipPermissions, agentType } = req.body;
|
|
1125
|
+
try {
|
|
1126
|
+
let newAgent;
|
|
1127
|
+
if (agentSessionId) {
|
|
1128
|
+
// Active agent: try reading events from memory first, fall back to disk
|
|
1129
|
+
const activeAgent = agentRegistry.getAgent(agentSessionId);
|
|
1130
|
+
if (!activeAgent) {
|
|
1131
|
+
return res.status(404).json({ error: `Active agent not found: ${agentSessionId}` });
|
|
1132
|
+
}
|
|
1133
|
+
const events = activeAgent.getEvents();
|
|
1134
|
+
const cliUuid = activeAgent.getCliSessionId?.();
|
|
1135
|
+
const parentId = cliUuid || agentSessionId;
|
|
1136
|
+
console.log(`[web] Chain request for ${agentSessionId}: events=${events.length}, cliUuid=${cliUuid || 'null'}, agentType=${activeAgent.getInfo().agentType}`);
|
|
1137
|
+
if (events.length > 0) {
|
|
1138
|
+
// In-memory events available — build chain directly
|
|
1139
|
+
newAgent = await agentRegistry.chainFromEvents(events, parentId, {
|
|
1140
|
+
cwd: cwd || undefined,
|
|
1141
|
+
skipPermissions: !!skipPermissions,
|
|
1142
|
+
agentType: agentType || undefined,
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
else if (cliUuid) {
|
|
1146
|
+
// No in-memory events (e.g. terminal-only adapter) — read from JSONL on disk
|
|
1147
|
+
newAgent = await agentRegistry.chainAgent(cliUuid, {
|
|
1148
|
+
cwd: cwd || undefined,
|
|
1149
|
+
skipPermissions: !!skipPermissions,
|
|
1150
|
+
agentType: agentType || undefined,
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
else {
|
|
1154
|
+
return res.status(400).json({
|
|
1155
|
+
error: 'Could not determine CLI session UUID. The session hook may not have fired — try sending a message first, then chain again.',
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
// Destroy the old active agent — the chain link replaces it
|
|
1159
|
+
agentRegistry.destroyAgent(agentSessionId).catch(err => {
|
|
1160
|
+
console.warn(`[web] Failed to destroy old agent after chain:`, err.message);
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
else if (sessionUuid) {
|
|
1164
|
+
// Historical session: read events from disk via factory
|
|
1165
|
+
newAgent = await agentRegistry.chainAgent(sessionUuid, {
|
|
1166
|
+
cwd: cwd || undefined,
|
|
1167
|
+
skipPermissions: !!skipPermissions,
|
|
1168
|
+
agentType: agentType || undefined,
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
else {
|
|
1172
|
+
return res.status(400).json({ error: 'agentSessionId or sessionUuid is required' });
|
|
1173
|
+
}
|
|
1174
|
+
res.json(newAgent.getInfo());
|
|
1175
|
+
}
|
|
1176
|
+
catch (err) {
|
|
1177
|
+
console.error(`[web] Failed to create chain session:`, err.message);
|
|
1178
|
+
res.status(500).json({ error: err.message });
|
|
1179
|
+
}
|
|
1180
|
+
});
|
|
1181
|
+
// Resume a historical session — optionally with a specific agent type
|
|
1182
|
+
app.post('/api/agent-sessions/resume', async (req, res) => {
|
|
1183
|
+
const { sessionUuid, cwd, skipPermissions, agentType } = req.body;
|
|
1184
|
+
if (!sessionUuid) {
|
|
1185
|
+
return res.status(400).json({ error: 'sessionUuid is required' });
|
|
1186
|
+
}
|
|
1187
|
+
try {
|
|
1188
|
+
const agent = await agentRegistry.resumeAgent(sessionUuid, {
|
|
1189
|
+
cwd: cwd || undefined,
|
|
1190
|
+
skipPermissions: !!skipPermissions,
|
|
1191
|
+
agentType: agentType || undefined,
|
|
1192
|
+
});
|
|
1193
|
+
res.json(agent.getInfo());
|
|
1194
|
+
}
|
|
1195
|
+
catch (err) {
|
|
1196
|
+
console.error(`[web] Failed to resume session:`, err.message);
|
|
1197
|
+
res.status(500).json({ error: err.message });
|
|
1198
|
+
}
|
|
1199
|
+
});
|
|
1200
|
+
// Destroy an agent session
|
|
1201
|
+
app.delete('/api/agent-sessions/:sessionId', async (req, res) => {
|
|
1202
|
+
const { sessionId } = req.params;
|
|
1203
|
+
try {
|
|
1204
|
+
await agentRegistry.destroyAgent(sessionId);
|
|
1205
|
+
res.json({ success: true });
|
|
1206
|
+
}
|
|
1207
|
+
catch (err) {
|
|
1208
|
+
res.status(500).json({ error: err.message });
|
|
1209
|
+
}
|
|
1210
|
+
});
|
|
1211
|
+
// Serve frontend static files (production)
|
|
1212
|
+
const projectRoot = PROJECT_ROOT;
|
|
1213
|
+
const frontendDist = path.join(projectRoot, 'frontend', 'dist');
|
|
1214
|
+
app.use(express.static(frontendDist));
|
|
1215
|
+
app.get('{*path}', (req, res, next) => {
|
|
1216
|
+
if (req.path.startsWith('/api/') || req.path.startsWith('/ws/'))
|
|
1217
|
+
return next();
|
|
1218
|
+
res.sendFile(path.join(frontendDist, 'index.html'), (err) => {
|
|
1219
|
+
if (err) {
|
|
1220
|
+
res.status(200).send(`
|
|
1221
|
+
<html>
|
|
1222
|
+
<body style="font-family:monospace;padding:2em;background:#1a1a2e;color:#e0e0e0">
|
|
1223
|
+
<h1>Atoo Studio</h1>
|
|
1224
|
+
<p>Frontend not built yet. Run <code>cd frontend && npm run build</code></p>
|
|
1225
|
+
<p>Or use the API directly:</p>
|
|
1226
|
+
<ul>
|
|
1227
|
+
<li>GET <a href="/api/status">/api/status</a></li>
|
|
1228
|
+
<li>GET <a href="/api/cli-environments">/api/cli-environments</a></li>
|
|
1229
|
+
<li>GET <a href="/api/agent-sessions">/api/agent-sessions</a></li>
|
|
1230
|
+
</ul>
|
|
1231
|
+
</body>
|
|
1232
|
+
</html>
|
|
1233
|
+
`);
|
|
1234
|
+
}
|
|
1235
|
+
});
|
|
1236
|
+
});
|
|
1237
|
+
const server = tlsOptions
|
|
1238
|
+
? https.createServer(tlsOptions, app)
|
|
1239
|
+
: http.createServer(app);
|
|
1240
|
+
// Broadcast file change events to session subscribers
|
|
1241
|
+
fsMonitor.onChangeEvent((change) => {
|
|
1242
|
+
const event = {
|
|
1243
|
+
type: 'file_change',
|
|
1244
|
+
change: {
|
|
1245
|
+
change_id: change.changeId,
|
|
1246
|
+
session_id: change.sessionId,
|
|
1247
|
+
timestamp: change.timestamp,
|
|
1248
|
+
pid: change.pid,
|
|
1249
|
+
operation: change.operation,
|
|
1250
|
+
path: change.path,
|
|
1251
|
+
old_path: change.oldPath || null,
|
|
1252
|
+
before_hash: change.beforeHash,
|
|
1253
|
+
after_hash: change.afterHash,
|
|
1254
|
+
file_size: change.fileSize,
|
|
1255
|
+
is_binary: change.isBinary,
|
|
1256
|
+
},
|
|
1257
|
+
};
|
|
1258
|
+
// Legacy: store.broadcastToSubscribers(change.sessionId, event);
|
|
1259
|
+
});
|
|
1260
|
+
// Settings WS clients for real-time sync across browser tabs
|
|
1261
|
+
const settingsClients = new Set();
|
|
1262
|
+
setBroadcastSettingsChange((scope, key, settings, excludeWs) => {
|
|
1263
|
+
const msg = JSON.stringify({ type: 'settings_change', scope, key, settings });
|
|
1264
|
+
for (const ws of settingsClients) {
|
|
1265
|
+
if (ws !== excludeWs && ws.readyState === 1) {
|
|
1266
|
+
ws.send(msg);
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
});
|
|
1270
|
+
// WebSocket for frontend live updates
|
|
1271
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
1272
|
+
server.on('upgrade', (req, socket, head) => {
|
|
1273
|
+
// Port proxy WebSocket upgrades — check first
|
|
1274
|
+
if (isPortProxyUpgrade(req)) {
|
|
1275
|
+
handlePortProxyUpgrade(portProxy, req, socket, head);
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
// Authenticate WebSocket upgrades when auth is enabled
|
|
1279
|
+
if (isAuthEnabled()) {
|
|
1280
|
+
const user = authenticateWsUpgrade(req);
|
|
1281
|
+
if (!user) {
|
|
1282
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
1283
|
+
socket.destroy();
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
req.user = user;
|
|
1287
|
+
}
|
|
1288
|
+
const url = req.url || '';
|
|
1289
|
+
// /ws/agent/:sessionId — abstract agent WebSocket (new)
|
|
1290
|
+
if (isAgentWsUpgrade(url)) {
|
|
1291
|
+
handleAgentWsUpgrade(wss, req, socket, head);
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
// /ws/preview/:projectId/:tabId — remote browser streaming
|
|
1295
|
+
if (isPreviewWsUpgrade(url)) {
|
|
1296
|
+
handlePreviewWsUpgrade(wss, req, socket, head);
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
// /ws/devtools/:projectId/:tabId/* — DevTools WebSocket proxy
|
|
1300
|
+
if (isDevtoolsWsUpgrade(url)) {
|
|
1301
|
+
handleDevtoolsWsUpgrade(wss, req, socket, head);
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
if (url.startsWith('/ws/status')) {
|
|
1305
|
+
// Global agent status stream for sidebar
|
|
1306
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1307
|
+
store.statusClients.add(ws);
|
|
1308
|
+
// Send current statuses
|
|
1309
|
+
for (const [sid, status] of store.agentStatuses.entries()) {
|
|
1310
|
+
ws.send(JSON.stringify({ type: 'agent_status', status, session_id: sid }));
|
|
1311
|
+
}
|
|
1312
|
+
// Send current context usage
|
|
1313
|
+
for (const [sid, usage] of store.contextUsages.entries()) {
|
|
1314
|
+
ws.send(JSON.stringify({ type: 'context_usage', session_id: sid, ...usage }));
|
|
1315
|
+
}
|
|
1316
|
+
// Send current context-in-progress state
|
|
1317
|
+
for (const sid of store.contextInProgressSessions) {
|
|
1318
|
+
ws.send(JSON.stringify({ type: 'context_in_progress', session_id: sid, inProgress: true }));
|
|
1319
|
+
}
|
|
1320
|
+
ws.on('message', (data) => {
|
|
1321
|
+
try {
|
|
1322
|
+
const msg = JSON.parse(data.toString());
|
|
1323
|
+
if (msg.type === 'session_focus' && msg.session_id) {
|
|
1324
|
+
agentRegistry.setSessionFocused(msg.session_id);
|
|
1325
|
+
}
|
|
1326
|
+
else if (msg.type === 'session_blur' && msg.session_id) {
|
|
1327
|
+
agentRegistry.setSessionBlurred(msg.session_id);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
catch { }
|
|
1331
|
+
});
|
|
1332
|
+
ws.on('close', () => store.statusClients.delete(ws));
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
else if (url.startsWith('/ws/settings')) {
|
|
1336
|
+
// Settings sync across browser tabs
|
|
1337
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1338
|
+
settingsClients.add(ws);
|
|
1339
|
+
ws.on('close', () => settingsClients.delete(ws));
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
else {
|
|
1343
|
+
// /ws/terminal/:sessionId — pty I/O for browser terminal
|
|
1344
|
+
const termMatch = url.match(/^\/ws\/terminal\/([^/?]+)/);
|
|
1345
|
+
if (termMatch) {
|
|
1346
|
+
const sessionId = termMatch[1];
|
|
1347
|
+
// Resolve agent session IDs to the underlying CLI envId
|
|
1348
|
+
let envId = getEnvIdForSession(sessionId);
|
|
1349
|
+
if (!envId) {
|
|
1350
|
+
// Try agent registry — agent sessions use agent_* IDs
|
|
1351
|
+
const agent = agentRegistry.getAgent(sessionId);
|
|
1352
|
+
if (agent) {
|
|
1353
|
+
envId = agent.envId;
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
if (!envId) {
|
|
1357
|
+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
1358
|
+
socket.destroy();
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
const ptyProcess = getPty(envId);
|
|
1362
|
+
if (!ptyProcess) {
|
|
1363
|
+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
1364
|
+
socket.destroy();
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1368
|
+
console.log(`[ws:terminal] Browser connected for session ${sessionId}`);
|
|
1369
|
+
// Keep-alive pings to prevent idle timeouts
|
|
1370
|
+
const pingInterval = setInterval(() => {
|
|
1371
|
+
if (ws.readyState === 1)
|
|
1372
|
+
ws.ping();
|
|
1373
|
+
}, 30000);
|
|
1374
|
+
// Get or create broadcast entry for this session's terminal
|
|
1375
|
+
if (!terminalClients.has(sessionId)) {
|
|
1376
|
+
// Seed scrollback from spawner's buffer (captures PTY output since spawn)
|
|
1377
|
+
const spawnerBuf = envId ? getScrollback(envId) : '';
|
|
1378
|
+
terminalClients.set(sessionId, { clients: new Set(), handler: null, scrollback: spawnerBuf });
|
|
1379
|
+
}
|
|
1380
|
+
const entry = terminalClients.get(sessionId);
|
|
1381
|
+
entry.clients.add(ws);
|
|
1382
|
+
// Replay scrollback buffer to this late-joining client
|
|
1383
|
+
if (entry.scrollback) {
|
|
1384
|
+
ws.send(JSON.stringify({ type: 'output', data: entry.scrollback }));
|
|
1385
|
+
}
|
|
1386
|
+
// Register single shared onData handler if first client.
|
|
1387
|
+
// PTY output is coalesced into ~16ms frames to avoid flicker:
|
|
1388
|
+
// TUI apps (ink, etc.) redraw by emitting clear + new content as
|
|
1389
|
+
// separate write() calls. Without buffering, each chunk becomes
|
|
1390
|
+
// its own WebSocket message and xterm may render an intermediate
|
|
1391
|
+
// "cleared" frame, causing visible scroll-up-then-down flicker.
|
|
1392
|
+
if (!entry.handler) {
|
|
1393
|
+
let pendingData = '';
|
|
1394
|
+
let flushTimer = null;
|
|
1395
|
+
const flushToClients = () => {
|
|
1396
|
+
flushTimer = null;
|
|
1397
|
+
if (!pendingData)
|
|
1398
|
+
return;
|
|
1399
|
+
const msg = JSON.stringify({ type: 'output', data: pendingData });
|
|
1400
|
+
pendingData = '';
|
|
1401
|
+
for (const client of entry.clients) {
|
|
1402
|
+
if (client.readyState === 1) {
|
|
1403
|
+
client.send(msg);
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
};
|
|
1407
|
+
entry.handler = ptyProcess.onData((data) => {
|
|
1408
|
+
// Append to scrollback buffer (ring-trim if too large)
|
|
1409
|
+
entry.scrollback += data;
|
|
1410
|
+
if (entry.scrollback.length > MAX_SCROLLBACK) {
|
|
1411
|
+
entry.scrollback = entry.scrollback.slice(-MAX_SCROLLBACK);
|
|
1412
|
+
}
|
|
1413
|
+
pendingData += data;
|
|
1414
|
+
if (!flushTimer) {
|
|
1415
|
+
flushTimer = setTimeout(flushToClients, 16);
|
|
1416
|
+
}
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
// Receive input/resize from browser
|
|
1420
|
+
ws.on('message', (raw) => {
|
|
1421
|
+
try {
|
|
1422
|
+
const msg = JSON.parse(raw.toString());
|
|
1423
|
+
if (msg.type === 'input' && typeof msg.data === 'string') {
|
|
1424
|
+
ptyProcess.write(msg.data);
|
|
1425
|
+
}
|
|
1426
|
+
else if (msg.type === 'resize' && msg.rows) {
|
|
1427
|
+
// Keep cols fixed at 120 for agent PTYs — only resize rows.
|
|
1428
|
+
// This ensures consistent output formatting regardless of
|
|
1429
|
+
// browser window size.
|
|
1430
|
+
ptyProcess.resize(120, msg.rows);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
catch { }
|
|
1434
|
+
});
|
|
1435
|
+
ws.on('close', () => {
|
|
1436
|
+
console.log(`[ws:terminal] Browser disconnected for session ${sessionId}`);
|
|
1437
|
+
clearInterval(pingInterval);
|
|
1438
|
+
entry.clients.delete(ws);
|
|
1439
|
+
// Keep handler alive so scrollback continues accumulating even with no browsers
|
|
1440
|
+
});
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
else {
|
|
1444
|
+
// /ws/serial/:requestId — serial device passthrough
|
|
1445
|
+
const serialMatch = url.match(/^\/ws\/serial\/([^/?]+)/);
|
|
1446
|
+
if (serialMatch) {
|
|
1447
|
+
const requestId = serialMatch[1];
|
|
1448
|
+
const serialReq = serialManager.getRequest(requestId);
|
|
1449
|
+
if (!serialReq || serialReq.status !== 'pending') {
|
|
1450
|
+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
1451
|
+
socket.destroy();
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1455
|
+
console.log(`[ws:serial] Browser connected for request ${requestId}`);
|
|
1456
|
+
// Link browser WS and start data piping
|
|
1457
|
+
serialManager.connectBrowser(requestId, ws);
|
|
1458
|
+
// Browser → PTY: binary = serial data, text = control messages
|
|
1459
|
+
ws.on('message', (data, isBinary) => {
|
|
1460
|
+
if (isBinary) {
|
|
1461
|
+
serialManager.handleBrowserData(requestId, Buffer.from(data));
|
|
1462
|
+
}
|
|
1463
|
+
else {
|
|
1464
|
+
try {
|
|
1465
|
+
const msg = JSON.parse(data.toString());
|
|
1466
|
+
serialManager.handleBrowserControl(requestId, msg);
|
|
1467
|
+
}
|
|
1468
|
+
catch { }
|
|
1469
|
+
}
|
|
1470
|
+
});
|
|
1471
|
+
ws.on('close', () => {
|
|
1472
|
+
console.log(`[ws:serial] Browser disconnected for request ${requestId}`);
|
|
1473
|
+
serialManager.closeRequest(requestId);
|
|
1474
|
+
});
|
|
1475
|
+
});
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
// /ws/shell/:id — standalone shell terminal
|
|
1479
|
+
const shellMatch = url.match(/^\/ws\/shell\/([^/?]+)/);
|
|
1480
|
+
if (shellMatch) {
|
|
1481
|
+
const shellId = shellMatch[1];
|
|
1482
|
+
const shellEntry = shellTerminals.get(shellId);
|
|
1483
|
+
if (!shellEntry) {
|
|
1484
|
+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
1485
|
+
socket.destroy();
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1489
|
+
console.log(`[ws:shell] Browser connected for shell ${shellId}`);
|
|
1490
|
+
const ptyProcess = shellEntry.pty;
|
|
1491
|
+
// Keep-alive pings to prevent idle timeouts
|
|
1492
|
+
const pingInterval = setInterval(() => {
|
|
1493
|
+
if (ws.readyState === 1)
|
|
1494
|
+
ws.ping();
|
|
1495
|
+
}, 30000);
|
|
1496
|
+
// Get or create broadcast entry for this shell
|
|
1497
|
+
if (!shellClients.has(shellId)) {
|
|
1498
|
+
shellClients.set(shellId, { clients: new Set(), handler: null, scrollback: '' });
|
|
1499
|
+
}
|
|
1500
|
+
const entry = shellClients.get(shellId);
|
|
1501
|
+
entry.clients.add(ws);
|
|
1502
|
+
// Replay scrollback buffer to this late-joining client
|
|
1503
|
+
if (entry.scrollback) {
|
|
1504
|
+
ws.send(JSON.stringify({ type: 'output', data: entry.scrollback }));
|
|
1505
|
+
}
|
|
1506
|
+
// Register single shared onData handler if first client
|
|
1507
|
+
// (coalesced into ~16ms frames — same anti-flicker logic as terminal handler)
|
|
1508
|
+
if (!entry.handler) {
|
|
1509
|
+
let pendingData = '';
|
|
1510
|
+
let flushTimer = null;
|
|
1511
|
+
const flushToClients = () => {
|
|
1512
|
+
flushTimer = null;
|
|
1513
|
+
if (!pendingData)
|
|
1514
|
+
return;
|
|
1515
|
+
const msg = JSON.stringify({ type: 'output', data: pendingData });
|
|
1516
|
+
pendingData = '';
|
|
1517
|
+
for (const client of entry.clients) {
|
|
1518
|
+
if (client.readyState === 1) {
|
|
1519
|
+
client.send(msg);
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
};
|
|
1523
|
+
entry.handler = ptyProcess.onData((data) => {
|
|
1524
|
+
entry.scrollback += data;
|
|
1525
|
+
if (entry.scrollback.length > MAX_SCROLLBACK) {
|
|
1526
|
+
entry.scrollback = entry.scrollback.slice(-MAX_SCROLLBACK);
|
|
1527
|
+
}
|
|
1528
|
+
pendingData += data;
|
|
1529
|
+
if (!flushTimer) {
|
|
1530
|
+
flushTimer = setTimeout(flushToClients, 16);
|
|
1531
|
+
}
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
ws.on('message', (raw) => {
|
|
1535
|
+
try {
|
|
1536
|
+
const msg = JSON.parse(raw.toString());
|
|
1537
|
+
if (msg.type === 'input' && typeof msg.data === 'string') {
|
|
1538
|
+
ptyProcess.write(msg.data);
|
|
1539
|
+
}
|
|
1540
|
+
else if (msg.type === 'resize' && msg.cols && msg.rows) {
|
|
1541
|
+
ptyProcess.resize(msg.cols, msg.rows);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
catch { }
|
|
1545
|
+
});
|
|
1546
|
+
ws.on('close', () => {
|
|
1547
|
+
console.log(`[ws:shell] Browser disconnected for shell ${shellId}`);
|
|
1548
|
+
clearInterval(pingInterval);
|
|
1549
|
+
entry.clients.delete(ws);
|
|
1550
|
+
// Keep handler alive so scrollback continues accumulating even with no browsers
|
|
1551
|
+
});
|
|
1552
|
+
});
|
|
1553
|
+
}
|
|
1554
|
+
// /ws/database-query/:connectionId — stream query results
|
|
1555
|
+
if (isDatabaseWsUpgrade(url)) {
|
|
1556
|
+
handleDatabaseWsUpgrade(wss, req, socket, head);
|
|
1557
|
+
return;
|
|
1558
|
+
}
|
|
1559
|
+
// /ws/container-logs/:runtime/:containerId — stream container logs
|
|
1560
|
+
const logsMatch = url.match(/^\/ws\/container-logs\/([^/?]+)\/([^/?]+)/);
|
|
1561
|
+
if (logsMatch) {
|
|
1562
|
+
const [, runtime, containerId] = logsMatch;
|
|
1563
|
+
const runtimes = getContainerRuntimes();
|
|
1564
|
+
const validRuntimes = ['docker', 'podman', 'lxc'];
|
|
1565
|
+
if (!validRuntimes.includes(runtime) || !runtimes[runtime]?.accessible) {
|
|
1566
|
+
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
1567
|
+
socket.destroy();
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
const idRegex = /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/;
|
|
1571
|
+
if (!idRegex.test(containerId) || containerId.length > 256) {
|
|
1572
|
+
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
1573
|
+
socket.destroy();
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1577
|
+
console.log(`[ws:container-logs] Streaming logs for ${runtime}/${containerId}`);
|
|
1578
|
+
const pingInterval = setInterval(() => {
|
|
1579
|
+
if (ws.readyState === 1)
|
|
1580
|
+
ws.ping();
|
|
1581
|
+
}, 30000);
|
|
1582
|
+
let cmd;
|
|
1583
|
+
let args;
|
|
1584
|
+
if (runtime === 'lxc') {
|
|
1585
|
+
cmd = 'lxc';
|
|
1586
|
+
args = ['exec', containerId, '--', 'journalctl', '-f'];
|
|
1587
|
+
}
|
|
1588
|
+
else {
|
|
1589
|
+
cmd = runtime;
|
|
1590
|
+
args = ['logs', '-f', '--tail', '200', containerId];
|
|
1591
|
+
}
|
|
1592
|
+
const logPty = pty.spawn(cmd, args, {
|
|
1593
|
+
name: 'xterm-256color',
|
|
1594
|
+
cols: 200,
|
|
1595
|
+
rows: 50,
|
|
1596
|
+
});
|
|
1597
|
+
const handler = logPty.onData((data) => {
|
|
1598
|
+
if (ws.readyState === 1) {
|
|
1599
|
+
ws.send(JSON.stringify({ type: 'output', data }));
|
|
1600
|
+
}
|
|
1601
|
+
});
|
|
1602
|
+
logPty.onExit(() => {
|
|
1603
|
+
if (ws.readyState === 1) {
|
|
1604
|
+
ws.send(JSON.stringify({ type: 'exit' }));
|
|
1605
|
+
ws.close();
|
|
1606
|
+
}
|
|
1607
|
+
});
|
|
1608
|
+
ws.on('close', () => {
|
|
1609
|
+
console.log(`[ws:container-logs] Disconnected for ${runtime}/${containerId}`);
|
|
1610
|
+
clearInterval(pingInterval);
|
|
1611
|
+
handler.dispose();
|
|
1612
|
+
logPty.kill();
|
|
1613
|
+
});
|
|
1614
|
+
});
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
// /ws/container-shell/:runtime/:containerId — interactive shell
|
|
1618
|
+
const shellContainerMatch = url.match(/^\/ws\/container-shell\/([^/?]+)\/([^/?]+)/);
|
|
1619
|
+
if (shellContainerMatch) {
|
|
1620
|
+
const [, runtime, containerId] = shellContainerMatch;
|
|
1621
|
+
const runtimes = getContainerRuntimes();
|
|
1622
|
+
const validRuntimes = ['docker', 'podman', 'lxc'];
|
|
1623
|
+
if (!validRuntimes.includes(runtime) || !runtimes[runtime]?.accessible) {
|
|
1624
|
+
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
1625
|
+
socket.destroy();
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
const idRegex = /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/;
|
|
1629
|
+
if (!idRegex.test(containerId) || containerId.length > 256) {
|
|
1630
|
+
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
1631
|
+
socket.destroy();
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1635
|
+
console.log(`[ws:container-shell] Shell into ${runtime}/${containerId}`);
|
|
1636
|
+
const pingInterval = setInterval(() => {
|
|
1637
|
+
if (ws.readyState === 1)
|
|
1638
|
+
ws.ping();
|
|
1639
|
+
}, 30000);
|
|
1640
|
+
let cmd;
|
|
1641
|
+
let args;
|
|
1642
|
+
if (runtime === 'lxc') {
|
|
1643
|
+
cmd = 'lxc';
|
|
1644
|
+
args = ['exec', containerId, '--', '/bin/sh'];
|
|
1645
|
+
}
|
|
1646
|
+
else {
|
|
1647
|
+
cmd = runtime;
|
|
1648
|
+
args = ['exec', '-it', containerId, '/bin/sh'];
|
|
1649
|
+
}
|
|
1650
|
+
const shellPty = pty.spawn(cmd, args, {
|
|
1651
|
+
name: 'xterm-256color',
|
|
1652
|
+
cols: 80,
|
|
1653
|
+
rows: 24,
|
|
1654
|
+
});
|
|
1655
|
+
const handler = shellPty.onData((data) => {
|
|
1656
|
+
if (ws.readyState === 1) {
|
|
1657
|
+
ws.send(JSON.stringify({ type: 'output', data }));
|
|
1658
|
+
}
|
|
1659
|
+
});
|
|
1660
|
+
shellPty.onExit(() => {
|
|
1661
|
+
if (ws.readyState === 1) {
|
|
1662
|
+
ws.send(JSON.stringify({ type: 'exit' }));
|
|
1663
|
+
ws.close();
|
|
1664
|
+
}
|
|
1665
|
+
});
|
|
1666
|
+
ws.on('message', (raw) => {
|
|
1667
|
+
try {
|
|
1668
|
+
const msg = JSON.parse(raw.toString());
|
|
1669
|
+
if (msg.type === 'input' && typeof msg.data === 'string') {
|
|
1670
|
+
shellPty.write(msg.data);
|
|
1671
|
+
}
|
|
1672
|
+
else if (msg.type === 'resize' && msg.cols && msg.rows) {
|
|
1673
|
+
shellPty.resize(msg.cols, msg.rows);
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
catch { }
|
|
1677
|
+
});
|
|
1678
|
+
ws.on('close', () => {
|
|
1679
|
+
console.log(`[ws:container-shell] Disconnected for ${runtime}/${containerId}`);
|
|
1680
|
+
clearInterval(pingInterval);
|
|
1681
|
+
handler.dispose();
|
|
1682
|
+
shellPty.kill();
|
|
1683
|
+
});
|
|
1684
|
+
});
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1687
|
+
if (!logsMatch && !shellContainerMatch && !shellMatch) {
|
|
1688
|
+
socket.destroy();
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
});
|
|
1693
|
+
return server;
|
|
1694
|
+
}
|