@aoagents/ao-web 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/BUILD_ID +1 -1
- package/.next/app-build-manifest.json +123 -131
- package/.next/app-path-routes-manifest.json +8 -9
- package/.next/build-manifest.json +5 -5
- package/.next/prerender-manifest.json +31 -31
- package/.next/react-loadable-manifest.json +14 -14
- package/.next/server/app/_not-found/page.js +1 -1
- package/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +6 -9
- package/.next/server/app/api/backlog/route.js +1 -1
- package/.next/server/app/api/backlog/route.js.nft.json +1 -1
- package/.next/server/app/api/backlog/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/browse-directory/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/filesystem/browse/route.js +1 -1
- package/.next/server/app/api/filesystem/browse/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/issues/route.js +1 -1
- package/.next/server/app/api/issues/route.js.nft.json +1 -1
- package/.next/server/app/api/issues/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/observability/route.js +1 -1
- package/.next/server/app/api/observability/route.js.nft.json +1 -1
- package/.next/server/app/api/observability/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/orchestrators/route.js +1 -1
- package/.next/server/app/api/orchestrators/route.js.nft.json +1 -1
- package/.next/server/app/api/orchestrators/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/route.js +1 -1
- package/.next/server/app/api/projects/[id]/route.js.nft.json +1 -1
- package/.next/server/app/api/projects/[id]/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/reload/route.js +1 -1
- package/.next/server/app/api/projects/reload/route.js.nft.json +1 -1
- package/.next/server/app/api/projects/reload/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/route.js +1 -1
- package/.next/server/app/api/projects/route.js.nft.json +1 -1
- package/.next/server/app/api/projects/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/prs/[id]/merge/route.js +1 -1
- package/.next/server/app/api/prs/[id]/merge/route.js.nft.json +1 -1
- package/.next/server/app/api/prs/[id]/merge/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/runtime/terminal/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/sessions/[id]/kill/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/kill/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/kill/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/sessions/[id]/message/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/message/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/message/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/sessions/[id]/remap/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/remap/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/remap/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/sessions/[id]/restore/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/restore/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/restore/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/sessions/[id]/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/sessions/[id]/send/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/send/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/send/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/sessions/patches/route.js +1 -1
- package/.next/server/app/api/sessions/patches/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/patches/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/sessions/route.js +1 -1
- package/.next/server/app/api/sessions/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/setup-labels/route.js +1 -1
- package/.next/server/app/api/setup-labels/route.js.nft.json +1 -1
- package/.next/server/app/api/setup-labels/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/spawn/route.js +1 -1
- package/.next/server/app/api/spawn/route.js.nft.json +1 -1
- package/.next/server/app/api/spawn/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/verify/route.js +1 -1
- package/.next/server/app/api/verify/route.js.nft.json +1 -1
- package/.next/server/app/api/verify/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/webhooks/[...slug]/route.js +1 -1
- package/.next/server/app/api/webhooks/[...slug]/route.js.nft.json +1 -1
- package/.next/server/app/api/webhooks/[...slug]/route_client-reference-manifest.js +1 -1
- package/.next/server/app/apple-icon/route.js +1 -1
- package/.next/server/app/apple-icon/route.js.nft.json +1 -1
- package/.next/server/app/apple-icon/route_client-reference-manifest.js +1 -1
- package/.next/server/app/apple-icon.body +0 -0
- package/.next/server/app/dev/terminal-test/page.js +3 -3
- package/.next/server/app/dev/terminal-test/page.js.nft.json +1 -1
- package/.next/server/app/dev/terminal-test/page_client-reference-manifest.js +1 -1
- package/.next/server/app/dev/terminal-test.html +1 -1
- package/.next/server/app/dev/terminal-test.rsc +7 -10
- package/.next/server/app/icon/route.js +1 -1
- package/.next/server/app/icon/route.js.nft.json +1 -1
- package/.next/server/app/icon/route_client-reference-manifest.js +1 -1
- package/.next/server/app/icon-192/route.js +1 -1
- package/.next/server/app/icon-192/route.js.nft.json +1 -1
- package/.next/server/app/icon-192/route_client-reference-manifest.js +1 -1
- package/.next/server/app/icon-512/route.js +1 -1
- package/.next/server/app/icon-512/route.js.nft.json +1 -1
- package/.next/server/app/icon-512/route_client-reference-manifest.js +1 -1
- package/.next/server/app/icon.body +0 -0
- package/.next/server/app/manifest.webmanifest/route.js +1 -1
- package/.next/server/app/manifest.webmanifest/route.js.nft.json +1 -1
- package/.next/server/app/manifest.webmanifest/route_client-reference-manifest.js +1 -1
- package/.next/server/app/manifest.webmanifest.body +1 -1
- package/.next/server/app/orchestrators/page.js +2 -2
- package/.next/server/app/orchestrators/page.js.nft.json +1 -1
- package/.next/server/app/orchestrators/page_client-reference-manifest.js +1 -1
- package/.next/server/app/page.js +1 -1
- package/.next/server/app/page.js.nft.json +1 -1
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app/projects/[projectId]/page.js +1 -1
- package/.next/server/app/projects/[projectId]/page.js.nft.json +1 -1
- package/.next/server/app/projects/[projectId]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/projects/[projectId]/sessions/[id]/page.js +2 -2
- package/.next/server/app/projects/[projectId]/sessions/[id]/page.js.nft.json +1 -1
- package/.next/server/app/projects/[projectId]/sessions/[id]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/projects/[projectId]/settings/page.js +1 -1
- package/.next/server/app/projects/[projectId]/settings/page.js.nft.json +1 -1
- package/.next/server/app/projects/[projectId]/settings/page_client-reference-manifest.js +1 -1
- package/.next/server/app/prs/page.js +1 -1
- package/.next/server/app/prs/page.js.nft.json +1 -1
- package/.next/server/app/prs/page_client-reference-manifest.js +1 -1
- package/.next/server/app/sessions/[id]/page.js +2 -2
- package/.next/server/app/sessions/[id]/page.js.nft.json +1 -1
- package/.next/server/app/sessions/[id]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/test-direct/page.js +1 -1
- package/.next/server/app/test-direct/page.js.nft.json +1 -1
- package/.next/server/app/test-direct/page_client-reference-manifest.js +1 -1
- package/.next/server/app/test-direct.html +1 -1
- package/.next/server/app/test-direct.rsc +7 -10
- package/.next/server/app-paths-manifest.json +8 -9
- package/.next/server/chunks/1172.js +1 -1
- package/.next/server/chunks/1271.js +1 -1
- package/.next/server/chunks/{6172.js → 1876.js} +2 -2
- package/.next/server/chunks/3131.js +3 -0
- package/.next/server/chunks/3714.js +1 -1
- package/.next/server/chunks/4520.js +1 -1
- package/.next/server/chunks/7167.js +1 -0
- package/.next/server/chunks/7173.js +9 -0
- package/.next/server/chunks/801.js +658 -0
- package/.next/server/chunks/9223.js +440 -0
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/.next/server/next-font-manifest.js +1 -1
- package/.next/server/next-font-manifest.json +1 -1
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/YR6Xi4DC5A7S7E2PoZuif/_buildManifest.js +1 -0
- package/.next/static/chunks/1383.c891a8ba72ee600c.js +1 -0
- package/.next/static/chunks/3764.89a5955e46eb74b4.js +1 -0
- package/.next/static/chunks/3780-52c4733ce6591b8d.js +1 -0
- package/.next/static/chunks/4465-17154f7a01abfe85.js +1 -0
- package/.next/static/chunks/5795-b96fd46c8c7344fc.js +1 -0
- package/.next/static/chunks/7008-6e85da85f12f9858.js +1 -0
- package/.next/static/chunks/app/_not-found/page-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/api/backlog/route-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/api/browse-directory/route-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/api/filesystem/browse/route-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/api/issues/route-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/api/observability/route-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/api/orchestrators/route-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/route-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/api/projects/reload/route-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/api/projects/route-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/api/prs/[id]/merge/route-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/api/runtime/terminal/route-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/kill/route-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/message/route-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/remap/route-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/restore/route-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/route-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/send/route-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/api/sessions/patches/route-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/api/sessions/route-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/api/setup-labels/route-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/api/spawn/route-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/api/verify/route-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/api/webhooks/[...slug]/route-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/apple-icon/route-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/dev/terminal-test/page-5819e40b3d4754ef.js +1 -0
- package/.next/static/chunks/app/error-da1d10c96ff5dd29.js +1 -0
- package/.next/static/chunks/app/icon/route-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/icon-192/route-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/icon-512/route-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/layout-bb6db479523cb3d6.js +1 -0
- package/.next/static/chunks/app/loading-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/manifest.webmanifest/route-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/orchestrators/page-f07983413ed1a44b.js +1 -0
- package/.next/static/chunks/app/{page-d3b83ad5f09b6ec7.js → page-6aa506a579ac9949.js} +1 -1
- package/.next/static/chunks/app/projects/[projectId]/loading-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/projects/[projectId]/{page-9f3dfbea006747cf.js → page-49eb5b990c74ca8f.js} +1 -1
- package/.next/static/chunks/app/projects/[projectId]/sessions/[id]/page-2450704c6b66a4b4.js +1 -0
- package/.next/static/chunks/app/projects/[projectId]/settings/page-d1da671e72a7bd5e.js +1 -0
- package/.next/static/chunks/app/prs/page-2332a7180a47f28c.js +1 -0
- package/.next/static/chunks/app/sessions/[id]/error-8de5b24e86eeae7b.js +1 -0
- package/.next/static/chunks/app/sessions/[id]/loading-3b8a01e726e988c8.js +1 -0
- package/.next/static/chunks/app/sessions/[id]/page-b60b49ccbafe51c9.js +1 -0
- package/.next/static/chunks/app/test-direct/page-eb366dde03fab6a7.js +1 -0
- package/.next/static/chunks/{webpack-d4ff5ed5e153ca3e.js → webpack-d2dfbd3e9262b74e.js} +1 -1
- package/.next/static/css/fcafd381715071b8.css +1 -0
- package/dist-server/mux-websocket.js +138 -127
- package/dist-server/tmux-utils.js +99 -9
- package/next.config.js +6 -0
- package/package.json +18 -16
- package/.next/server/app/api/events/route.js +0 -9
- package/.next/server/app/api/events/route.js.nft.json +0 -1
- package/.next/server/app/api/events/route_client-reference-manifest.js +0 -1
- package/.next/server/chunks/252.js +0 -11
- package/.next/server/chunks/2810.js +0 -1
- package/.next/server/chunks/3602.js +0 -382
- package/.next/server/chunks/3667.js +0 -277
- package/.next/server/chunks/8367.js +0 -3
- package/.next/static/chunks/3697.4d6d86c5f0caf73e.js +0 -1
- package/.next/static/chunks/4465-aaba60a6355de914.js +0 -1
- package/.next/static/chunks/6078.47ce88bee96cfaee.js +0 -1
- package/.next/static/chunks/6607-405ce4d15e595f4a.js +0 -1
- package/.next/static/chunks/7008-71ebb186f0549f41.js +0 -1
- package/.next/static/chunks/9331-fcdd652218ac6f68.js +0 -1
- package/.next/static/chunks/app/_not-found/page-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/api/backlog/route-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/api/browse-directory/route-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/api/events/route-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/api/filesystem/browse/route-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/api/issues/route-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/api/observability/route-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/api/orchestrators/route-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/route-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/api/projects/reload/route-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/api/projects/route-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/api/prs/[id]/merge/route-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/api/runtime/terminal/route-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/api/sessions/[id]/kill/route-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/api/sessions/[id]/message/route-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/api/sessions/[id]/remap/route-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/api/sessions/[id]/restore/route-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/api/sessions/[id]/route-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/api/sessions/[id]/send/route-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/api/sessions/patches/route-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/api/sessions/route-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/api/setup-labels/route-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/api/spawn/route-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/api/verify/route-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/api/webhooks/[...slug]/route-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/apple-icon/route-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/dev/terminal-test/page-5765f0465db56fed.js +0 -1
- package/.next/static/chunks/app/error-670f1d8bf2b6859c.js +0 -1
- package/.next/static/chunks/app/icon/route-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/icon-192/route-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/icon-512/route-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/layout-ddf79478d9a673bb.js +0 -1
- package/.next/static/chunks/app/loading-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/manifest.webmanifest/route-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/orchestrators/page-4c190484788aaaa3.js +0 -1
- package/.next/static/chunks/app/projects/[projectId]/loading-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/projects/[projectId]/sessions/[id]/page-a7090a06948f9a6b.js +0 -1
- package/.next/static/chunks/app/projects/[projectId]/settings/page-219df6dcbc6d1107.js +0 -1
- package/.next/static/chunks/app/prs/page-a285e930235a23af.js +0 -1
- package/.next/static/chunks/app/sessions/[id]/error-eb0973907da68a37.js +0 -1
- package/.next/static/chunks/app/sessions/[id]/loading-e2dea9178b4af8db.js +0 -1
- package/.next/static/chunks/app/sessions/[id]/page-98f398b822092218.js +0 -1
- package/.next/static/chunks/app/test-direct/page-f9bff7d7b4dfe728.js +0 -1
- package/.next/static/css/46a7e8e962075e54.css +0 -1
- package/.next/static/css/577ea7f5ad4f0576.css +0 -1
- package/.next/static/media/4cf2300e9c8272f7-s.p.woff2 +0 -0
- package/.next/static/media/558ca1a6aa3cb55e-s.p.woff2 +0 -0
- package/.next/static/media/64d784ea54a4acde-s.woff2 +0 -0
- package/.next/static/media/6d831b18ae5b01dc-s.woff2 +0 -0
- package/.next/static/media/8d697b304b401681-s.woff2 +0 -0
- package/.next/static/media/ac0e76ddaeeb7981-s.woff2 +0 -0
- package/.next/static/media/ba015fad6dcf6784-s.woff2 +0 -0
- package/.next/static/media/edc640959b0c7826-s.woff2 +0 -0
- package/.next/static/media/ff71da380fbe67dd-s.woff2 +0 -0
- package/.next/static/q6yh3n8jgvyI9JIbexzuH/_buildManifest.js +0 -1
- /package/.next/static/{q6yh3n8jgvyI9JIbexzuH → YR6Xi4DC5A7S7E2PoZuif}/_ssgManifest.js +0 -0
|
@@ -2,46 +2,77 @@
|
|
|
2
2
|
* Multiplexed WebSocket server for terminal multiplexing.
|
|
3
3
|
* Manages multiple terminal connections over a single persistent WebSocket.
|
|
4
4
|
*
|
|
5
|
-
* Session updates are delivered via
|
|
6
|
-
*
|
|
7
|
-
* This replaces per-client HTTP polling and makes session updates event-driven.
|
|
5
|
+
* Session updates are delivered via polling of Next.js /api/sessions/patches
|
|
6
|
+
* every 3s, then broadcast to all subscribed clients via WebSocket.
|
|
8
7
|
*/
|
|
9
8
|
import { WebSocketServer, WebSocket } from "ws";
|
|
10
9
|
import { homedir, userInfo } from "node:os";
|
|
11
10
|
import { spawn } from "node:child_process";
|
|
12
11
|
import { findTmux, resolveTmuxSession, validateSessionId } from "./tmux-utils.js";
|
|
13
12
|
/**
|
|
14
|
-
* Manages
|
|
15
|
-
* Broadcasts
|
|
16
|
-
* Lazily
|
|
13
|
+
* Manages polling of session patches from Next.js /api/sessions/patches.
|
|
14
|
+
* Broadcasts to all subscribed callbacks.
|
|
15
|
+
* Lazily starts polling on first subscriber, stops when the last one leaves.
|
|
17
16
|
*/
|
|
18
17
|
export class SessionBroadcaster {
|
|
19
18
|
subscribers = new Set();
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
errorSubscribers = new Set();
|
|
20
|
+
intervalId = null;
|
|
21
|
+
polling = false;
|
|
22
22
|
baseUrl;
|
|
23
23
|
constructor(nextPort) {
|
|
24
24
|
this.baseUrl = `http://localhost:${nextPort}`;
|
|
25
25
|
}
|
|
26
26
|
/**
|
|
27
|
-
* Subscribe to session patches. Returns an unsubscribe function.
|
|
28
|
-
* Sends an immediate snapshot to the new subscriber, then
|
|
27
|
+
* Subscribe to session patches and errors. Returns an unsubscribe function.
|
|
28
|
+
* Sends an immediate snapshot to the new subscriber, then polling updates.
|
|
29
29
|
*/
|
|
30
|
-
subscribe(callback) {
|
|
30
|
+
subscribe(callback, onError) {
|
|
31
31
|
const wasEmpty = this.subscribers.size === 0;
|
|
32
32
|
this.subscribers.add(callback);
|
|
33
|
+
if (onError)
|
|
34
|
+
this.errorSubscribers.add(onError);
|
|
33
35
|
// Immediately send a one-off snapshot to just this new subscriber
|
|
34
|
-
void this.fetchSnapshot().then((
|
|
35
|
-
if (sessions && this.subscribers.has(callback)) {
|
|
36
|
-
|
|
36
|
+
void this.fetchSnapshot().then((result) => {
|
|
37
|
+
if (result.sessions && this.subscribers.has(callback)) {
|
|
38
|
+
try {
|
|
39
|
+
callback(result.sessions);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Isolate subscriber errors so one bad subscriber doesn't break others
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
else if (result.error && onError && this.errorSubscribers.has(onError)) {
|
|
46
|
+
try {
|
|
47
|
+
onError(result.error);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Isolate subscriber errors
|
|
51
|
+
}
|
|
37
52
|
}
|
|
38
53
|
});
|
|
39
|
-
// Start
|
|
54
|
+
// Start polling if this is the first subscriber
|
|
40
55
|
if (wasEmpty) {
|
|
41
|
-
|
|
56
|
+
this.intervalId = setInterval(() => {
|
|
57
|
+
if (this.polling)
|
|
58
|
+
return;
|
|
59
|
+
this.polling = true;
|
|
60
|
+
void this.fetchSnapshot()
|
|
61
|
+
.then((result) => {
|
|
62
|
+
if (result.sessions && this.intervalId !== null)
|
|
63
|
+
this.broadcast(result.sessions);
|
|
64
|
+
else if (result.error && this.intervalId !== null)
|
|
65
|
+
this.broadcastError(result.error);
|
|
66
|
+
})
|
|
67
|
+
.finally(() => {
|
|
68
|
+
this.polling = false;
|
|
69
|
+
});
|
|
70
|
+
}, 3000);
|
|
42
71
|
}
|
|
43
72
|
return () => {
|
|
44
73
|
this.subscribers.delete(callback);
|
|
74
|
+
if (onError)
|
|
75
|
+
this.errorSubscribers.delete(onError);
|
|
45
76
|
if (this.subscribers.size === 0) {
|
|
46
77
|
this.disconnect();
|
|
47
78
|
}
|
|
@@ -57,99 +88,45 @@ export class SessionBroadcaster {
|
|
|
57
88
|
}
|
|
58
89
|
}
|
|
59
90
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
try {
|
|
63
|
-
const controller = new AbortController();
|
|
64
|
-
const timeoutId = setTimeout(() => controller.abort(), 4000);
|
|
91
|
+
broadcastError(error) {
|
|
92
|
+
for (const callback of this.errorSubscribers) {
|
|
65
93
|
try {
|
|
66
|
-
|
|
67
|
-
signal: controller.signal,
|
|
68
|
-
});
|
|
69
|
-
clearTimeout(timeoutId);
|
|
70
|
-
if (!res.ok)
|
|
71
|
-
return null;
|
|
72
|
-
const data = (await res.json());
|
|
73
|
-
return data.sessions ?? null;
|
|
94
|
+
callback(error);
|
|
74
95
|
}
|
|
75
|
-
catch {
|
|
76
|
-
|
|
77
|
-
return null;
|
|
96
|
+
catch (err) {
|
|
97
|
+
console.error("[MuxServer] Session error subscriber threw:", err);
|
|
78
98
|
}
|
|
79
99
|
}
|
|
80
|
-
catch {
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
83
100
|
}
|
|
84
|
-
/**
|
|
85
|
-
async
|
|
86
|
-
if (this.abortController)
|
|
87
|
-
return;
|
|
101
|
+
/** One-shot HTTP fetch of the current session list. */
|
|
102
|
+
async fetchSnapshot() {
|
|
88
103
|
const controller = new AbortController();
|
|
89
|
-
|
|
90
|
-
const { signal } = controller;
|
|
104
|
+
const timeoutId = setTimeout(() => controller.abort(), 4000);
|
|
91
105
|
try {
|
|
92
|
-
const res = await fetch(`${this.baseUrl}/api/
|
|
93
|
-
signal,
|
|
94
|
-
headers: { Accept: "text/event-stream" },
|
|
106
|
+
const res = await fetch(`${this.baseUrl}/api/sessions/patches`, {
|
|
107
|
+
signal: controller.signal,
|
|
95
108
|
});
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
let buffer = "";
|
|
102
|
-
while (!signal.aborted) {
|
|
103
|
-
const { done, value } = await reader.read();
|
|
104
|
-
if (done)
|
|
105
|
-
break;
|
|
106
|
-
buffer += decoder.decode(value, { stream: true });
|
|
107
|
-
const lines = buffer.split("\n");
|
|
108
|
-
buffer = lines.pop() ?? "";
|
|
109
|
-
for (const line of lines) {
|
|
110
|
-
if (!line.startsWith("data: "))
|
|
111
|
-
continue;
|
|
112
|
-
try {
|
|
113
|
-
const event = JSON.parse(line.slice(6));
|
|
114
|
-
if (event.type === "snapshot" && event.sessions) {
|
|
115
|
-
this.broadcast(event.sessions);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
catch {
|
|
119
|
-
// ignore malformed events
|
|
120
|
-
}
|
|
121
|
-
}
|
|
109
|
+
clearTimeout(timeoutId);
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
const msg = `Session fetch failed: HTTP ${res.status}`;
|
|
112
|
+
console.warn(`[SessionBroadcaster] ${msg}`);
|
|
113
|
+
return { sessions: null, error: msg };
|
|
122
114
|
}
|
|
115
|
+
const data = (await res.json());
|
|
116
|
+
return { sessions: data.sessions ?? null, error: null };
|
|
123
117
|
}
|
|
124
118
|
catch (err) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
console.warn("[
|
|
128
|
-
|
|
129
|
-
finally {
|
|
130
|
-
// Only clear our own controller — a concurrent connect() may have already
|
|
131
|
-
// set a new one (e.g. disconnect() → subscribe() → connect() in the same tick).
|
|
132
|
-
if (this.abortController === controller) {
|
|
133
|
-
this.abortController = null;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
// Reconnect with backoff if there are still subscribers
|
|
137
|
-
if (this.subscribers.size > 0) {
|
|
138
|
-
console.log("[MuxServer] SSE reconnecting in 5s");
|
|
139
|
-
this.reconnectTimer = setTimeout(() => {
|
|
140
|
-
this.reconnectTimer = null;
|
|
141
|
-
if (this.subscribers.size > 0)
|
|
142
|
-
void this.connect();
|
|
143
|
-
}, 5000);
|
|
119
|
+
clearTimeout(timeoutId);
|
|
120
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
121
|
+
console.warn("[SessionBroadcaster] fetchSnapshot error:", msg);
|
|
122
|
+
return { sessions: null, error: msg };
|
|
144
123
|
}
|
|
145
124
|
}
|
|
146
125
|
disconnect() {
|
|
147
|
-
if (this.
|
|
148
|
-
|
|
149
|
-
this.
|
|
126
|
+
if (this.intervalId !== null) {
|
|
127
|
+
clearInterval(this.intervalId);
|
|
128
|
+
this.intervalId = null;
|
|
150
129
|
}
|
|
151
|
-
this.abortController?.abort();
|
|
152
|
-
this.abortController = null;
|
|
153
130
|
}
|
|
154
131
|
}
|
|
155
132
|
let ptySpawn;
|
|
@@ -162,6 +139,7 @@ catch (err) {
|
|
|
162
139
|
console.warn("[MuxServer] node-pty not available — mux server will be disabled.", err);
|
|
163
140
|
}
|
|
164
141
|
const RING_BUFFER_MAX = 50 * 1024; // 50KB max per terminal
|
|
142
|
+
const WS_BUFFER_HIGH_WATERMARK = 64 * 1024; // 64KB
|
|
165
143
|
const MAX_REATTACH_ATTEMPTS = 3;
|
|
166
144
|
/**
|
|
167
145
|
* TerminalManager manages PTY processes independently of WebSocket connections.
|
|
@@ -173,21 +151,27 @@ class TerminalManager {
|
|
|
173
151
|
constructor(tmuxPath) {
|
|
174
152
|
this.TMUX = tmuxPath ?? findTmux();
|
|
175
153
|
}
|
|
154
|
+
terminalKey(id, projectId) {
|
|
155
|
+
return projectId ? `${projectId}:${id}` : id;
|
|
156
|
+
}
|
|
176
157
|
/**
|
|
177
158
|
* Open/attach to a terminal. If already open, just return.
|
|
178
159
|
* If has subscribers but PTY crashed, re-attach.
|
|
179
160
|
*/
|
|
180
|
-
open(id) {
|
|
181
|
-
// Validate and resolve
|
|
161
|
+
open(id, projectId, tmuxName) {
|
|
182
162
|
if (!validateSessionId(id)) {
|
|
183
163
|
throw new Error(`Invalid session ID: ${id}`);
|
|
184
164
|
}
|
|
185
|
-
const
|
|
165
|
+
const key = this.terminalKey(id, projectId);
|
|
166
|
+
const existing = this.terminals.get(key);
|
|
167
|
+
const tmuxSessionId = tmuxName ??
|
|
168
|
+
existing?.tmuxSessionId ??
|
|
169
|
+
resolveTmuxSession(id, this.TMUX, undefined, undefined, projectId);
|
|
186
170
|
if (!tmuxSessionId) {
|
|
187
171
|
throw new Error(`Session not found: ${id}`);
|
|
188
172
|
}
|
|
189
173
|
// Get or create terminal entry
|
|
190
|
-
let terminal = this.terminals.get(
|
|
174
|
+
let terminal = this.terminals.get(key);
|
|
191
175
|
if (!terminal) {
|
|
192
176
|
terminal = {
|
|
193
177
|
id,
|
|
@@ -199,19 +183,20 @@ class TerminalManager {
|
|
|
199
183
|
bufferBytes: 0,
|
|
200
184
|
reattachAttempts: 0,
|
|
201
185
|
};
|
|
202
|
-
this.terminals.set(
|
|
186
|
+
this.terminals.set(key, terminal);
|
|
203
187
|
}
|
|
204
188
|
// If PTY is already attached, we're done
|
|
205
189
|
if (terminal.pty) {
|
|
206
190
|
return tmuxSessionId;
|
|
207
191
|
}
|
|
208
192
|
// Enable mouse mode
|
|
209
|
-
const
|
|
193
|
+
const exactTmuxTarget = `=${tmuxSessionId}`;
|
|
194
|
+
const mouseProc = spawn(this.TMUX, ["set-option", "-t", exactTmuxTarget, "mouse", "on"]);
|
|
210
195
|
mouseProc.on("error", (err) => {
|
|
211
196
|
console.error(`[MuxServer] Failed to set mouse mode for ${tmuxSessionId}:`, err.message);
|
|
212
197
|
});
|
|
213
198
|
// Hide the status bar
|
|
214
|
-
const statusProc = spawn(this.TMUX, ["set-option", "-t",
|
|
199
|
+
const statusProc = spawn(this.TMUX, ["set-option", "-t", exactTmuxTarget, "status", "off"]);
|
|
215
200
|
statusProc.on("error", (err) => {
|
|
216
201
|
console.error(`[MuxServer] Failed to hide status bar for ${tmuxSessionId}:`, err.message);
|
|
217
202
|
});
|
|
@@ -231,7 +216,7 @@ class TerminalManager {
|
|
|
231
216
|
throw new Error("node-pty not available");
|
|
232
217
|
}
|
|
233
218
|
// Spawn PTY
|
|
234
|
-
const pty = ptySpawn(this.TMUX, ["attach-session", "-t",
|
|
219
|
+
const pty = ptySpawn(this.TMUX, ["attach-session", "-t", exactTmuxTarget], {
|
|
235
220
|
name: "xterm-256color",
|
|
236
221
|
cols: 80,
|
|
237
222
|
rows: 24,
|
|
@@ -271,7 +256,7 @@ class TerminalManager {
|
|
|
271
256
|
terminal.reattachAttempts += 1;
|
|
272
257
|
console.log(`[MuxServer] Re-attaching to ${id} (attempt ${terminal.reattachAttempts}/${MAX_REATTACH_ATTEMPTS})`);
|
|
273
258
|
try {
|
|
274
|
-
this.open(id);
|
|
259
|
+
this.open(id, projectId, tmuxSessionId);
|
|
275
260
|
terminal.reattachAttempts = 0; // reset on successful attach
|
|
276
261
|
return; // re-attached — don't notify exit
|
|
277
262
|
}
|
|
@@ -293,8 +278,8 @@ class TerminalManager {
|
|
|
293
278
|
/**
|
|
294
279
|
* Write data to the PTY if attached
|
|
295
280
|
*/
|
|
296
|
-
write(id, data) {
|
|
297
|
-
const terminal = this.terminals.get(id);
|
|
281
|
+
write(id, data, projectId) {
|
|
282
|
+
const terminal = this.terminals.get(this.terminalKey(id, projectId));
|
|
298
283
|
if (terminal?.pty) {
|
|
299
284
|
terminal.pty.write(data);
|
|
300
285
|
}
|
|
@@ -302,8 +287,8 @@ class TerminalManager {
|
|
|
302
287
|
/**
|
|
303
288
|
* Resize the PTY if attached
|
|
304
289
|
*/
|
|
305
|
-
resize(id, cols, rows) {
|
|
306
|
-
const terminal = this.terminals.get(id);
|
|
290
|
+
resize(id, cols, rows, projectId) {
|
|
291
|
+
const terminal = this.terminals.get(this.terminalKey(id, projectId));
|
|
307
292
|
if (terminal?.pty) {
|
|
308
293
|
terminal.pty.resize(cols, rows);
|
|
309
294
|
}
|
|
@@ -313,10 +298,11 @@ class TerminalManager {
|
|
|
313
298
|
* Automatically opens the terminal if needed.
|
|
314
299
|
* @param onExit - called when the PTY exits and cannot be re-attached
|
|
315
300
|
*/
|
|
316
|
-
subscribe(id, callback, onExit) {
|
|
301
|
+
subscribe(id, projectId, callback, onExit) {
|
|
317
302
|
// Ensure terminal is open
|
|
318
|
-
this.open(id);
|
|
319
|
-
const
|
|
303
|
+
this.open(id, projectId);
|
|
304
|
+
const key = this.terminalKey(id, projectId);
|
|
305
|
+
const terminal = this.terminals.get(key);
|
|
320
306
|
if (!terminal) {
|
|
321
307
|
throw new Error(`Failed to open terminal: ${id}`);
|
|
322
308
|
}
|
|
@@ -335,15 +321,15 @@ class TerminalManager {
|
|
|
335
321
|
terminal.pty.kill();
|
|
336
322
|
terminal.pty = null;
|
|
337
323
|
}
|
|
338
|
-
this.terminals.delete(
|
|
324
|
+
this.terminals.delete(key);
|
|
339
325
|
}
|
|
340
326
|
};
|
|
341
327
|
}
|
|
342
328
|
/**
|
|
343
329
|
* Get buffered data for a terminal
|
|
344
330
|
*/
|
|
345
|
-
getBuffer(id) {
|
|
346
|
-
const terminal = this.terminals.get(id);
|
|
331
|
+
getBuffer(id, projectId) {
|
|
332
|
+
const terminal = this.terminals.get(this.terminalKey(id, projectId));
|
|
347
333
|
if (!terminal)
|
|
348
334
|
return "";
|
|
349
335
|
return terminal.buffer.join("");
|
|
@@ -400,60 +386,75 @@ export function createMuxWebSocket(tmuxPath) {
|
|
|
400
386
|
}
|
|
401
387
|
else if (msg.ch === "terminal") {
|
|
402
388
|
const { id, type } = msg;
|
|
389
|
+
const projectId = "projectId" in msg ? msg.projectId : undefined;
|
|
390
|
+
const subscriptionKey = projectId ? `${projectId}:${id}` : id;
|
|
403
391
|
try {
|
|
404
392
|
if (type === "open") {
|
|
405
393
|
// Validate session exists
|
|
406
|
-
terminalManager.open(id);
|
|
394
|
+
terminalManager.open(id, projectId, "tmuxName" in msg ? msg.tmuxName : undefined);
|
|
407
395
|
// Send opened confirmation (idempotent — safe to send on re-open)
|
|
408
|
-
const openedMsg = {
|
|
396
|
+
const openedMsg = {
|
|
397
|
+
ch: "terminal",
|
|
398
|
+
id,
|
|
399
|
+
type: "opened",
|
|
400
|
+
...(projectId && { projectId }),
|
|
401
|
+
};
|
|
409
402
|
ws.send(JSON.stringify(openedMsg));
|
|
410
403
|
// Subscribe and send history buffer only for new subscribers.
|
|
411
404
|
// Skipping the buffer on re-open prevents duplicate output when
|
|
412
405
|
// MuxProvider re-sends open for all terminals on reconnect.
|
|
413
|
-
if (!subscriptions.has(
|
|
406
|
+
if (!subscriptions.has(subscriptionKey)) {
|
|
414
407
|
// Send buffered history to catch up the new subscriber
|
|
415
|
-
const buffer = terminalManager.getBuffer(id);
|
|
408
|
+
const buffer = terminalManager.getBuffer(id, projectId);
|
|
416
409
|
if (buffer) {
|
|
417
410
|
const bufferMsg = {
|
|
418
411
|
ch: "terminal",
|
|
419
412
|
id,
|
|
420
413
|
type: "data",
|
|
421
414
|
data: buffer,
|
|
415
|
+
...(projectId && { projectId }),
|
|
422
416
|
};
|
|
423
417
|
ws.send(JSON.stringify(bufferMsg));
|
|
424
418
|
}
|
|
425
|
-
const unsub = terminalManager.subscribe(id, (data) => {
|
|
419
|
+
const unsub = terminalManager.subscribe(id, projectId, (data) => {
|
|
426
420
|
const dataMsg = {
|
|
427
421
|
ch: "terminal",
|
|
428
422
|
id,
|
|
429
423
|
type: "data",
|
|
430
424
|
data,
|
|
425
|
+
...(projectId && { projectId }),
|
|
431
426
|
};
|
|
432
427
|
if (ws.readyState === WebSocket.OPEN) {
|
|
433
428
|
ws.send(JSON.stringify(dataMsg));
|
|
434
429
|
}
|
|
435
430
|
}, (exitCode) => {
|
|
436
|
-
const exitedMsg = {
|
|
431
|
+
const exitedMsg = {
|
|
432
|
+
ch: "terminal",
|
|
433
|
+
id,
|
|
434
|
+
type: "exited",
|
|
435
|
+
code: exitCode,
|
|
436
|
+
...(projectId && { projectId }),
|
|
437
|
+
};
|
|
437
438
|
if (ws.readyState === WebSocket.OPEN) {
|
|
438
439
|
ws.send(JSON.stringify(exitedMsg));
|
|
439
440
|
}
|
|
440
441
|
});
|
|
441
|
-
subscriptions.set(
|
|
442
|
+
subscriptions.set(subscriptionKey, unsub);
|
|
442
443
|
}
|
|
443
444
|
}
|
|
444
445
|
else if (type === "data" && "data" in msg) {
|
|
445
|
-
terminalManager.write(id, msg.data);
|
|
446
|
+
terminalManager.write(id, msg.data, projectId);
|
|
446
447
|
}
|
|
447
448
|
else if (type === "resize" && "cols" in msg && "rows" in msg) {
|
|
448
|
-
terminalManager.resize(id, msg.cols, msg.rows);
|
|
449
|
+
terminalManager.resize(id, msg.cols, msg.rows, projectId);
|
|
449
450
|
}
|
|
450
451
|
else if (type === "close") {
|
|
451
452
|
// Unsubscribe this client only — TerminalManager is shared across
|
|
452
453
|
// all mux connections so we must not kill the PTY here.
|
|
453
|
-
const unsub = subscriptions.get(
|
|
454
|
+
const unsub = subscriptions.get(subscriptionKey);
|
|
454
455
|
if (unsub) {
|
|
455
456
|
unsub();
|
|
456
|
-
subscriptions.delete(
|
|
457
|
+
subscriptions.delete(subscriptionKey);
|
|
457
458
|
}
|
|
458
459
|
}
|
|
459
460
|
}
|
|
@@ -464,6 +465,7 @@ export function createMuxWebSocket(tmuxPath) {
|
|
|
464
465
|
id,
|
|
465
466
|
type: "error",
|
|
466
467
|
message: err instanceof Error ? err.message : String(err),
|
|
468
|
+
...(projectId && { projectId }),
|
|
467
469
|
};
|
|
468
470
|
ws.send(JSON.stringify(errorMsg));
|
|
469
471
|
}
|
|
@@ -472,10 +474,19 @@ export function createMuxWebSocket(tmuxPath) {
|
|
|
472
474
|
else if (msg.ch === "subscribe") {
|
|
473
475
|
if (msg.topics.includes("sessions") && !sessionUnsubscribe) {
|
|
474
476
|
sessionUnsubscribe = broadcaster.subscribe((sessions) => {
|
|
475
|
-
if (ws.readyState
|
|
476
|
-
|
|
477
|
-
|
|
477
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
478
|
+
return;
|
|
479
|
+
if (ws.bufferedAmount > WS_BUFFER_HIGH_WATERMARK) {
|
|
480
|
+
console.warn("[MuxServer] Skipping session snapshot — socket backpressured");
|
|
481
|
+
return;
|
|
478
482
|
}
|
|
483
|
+
const snapMsg = { ch: "sessions", type: "snapshot", sessions };
|
|
484
|
+
ws.send(JSON.stringify(snapMsg));
|
|
485
|
+
}, (error) => {
|
|
486
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
487
|
+
return;
|
|
488
|
+
const errMsg = { ch: "sessions", type: "error", error };
|
|
489
|
+
ws.send(JSON.stringify(errMsg));
|
|
479
490
|
});
|
|
480
491
|
}
|
|
481
492
|
}
|
|
@@ -5,10 +5,74 @@
|
|
|
5
5
|
* so the logic can be properly unit tested.
|
|
6
6
|
*/
|
|
7
7
|
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { readdirSync, existsSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
8
11
|
/** Session ID validation regex — alphanumeric, hyphens, underscores only */
|
|
9
12
|
export const SESSION_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
10
13
|
/** Hash prefix pattern — 12-char lowercase hex, as generated by generateConfigHash */
|
|
11
14
|
const HASH_PREFIX_PATTERN = /^[a-f0-9]{12}-/;
|
|
15
|
+
/**
|
|
16
|
+
* StorageKey pattern — either bare 12-hex hash or `{hash}-{projectName}`
|
|
17
|
+
* wrapped form. The wrapped suffix comes from `basename(projectPath)` on
|
|
18
|
+
* disk so it can contain any character a filesystem path component allows
|
|
19
|
+
* (spaces, unicode, etc.); we only require that something non-empty
|
|
20
|
+
* follows the `-` separator. Security: storageKey is passed to
|
|
21
|
+
* `execFileSync` which bypasses the shell, so arbitrary characters are
|
|
22
|
+
* safe in the argv.
|
|
23
|
+
*/
|
|
24
|
+
const STORAGE_KEY_PATTERN = /^[a-f0-9]{12}(-.+)?$/;
|
|
25
|
+
const defaultFs = {
|
|
26
|
+
// Only return subdirectory names. `readdirSync` without withFileTypes
|
|
27
|
+
// includes plain files, so a stray file like `aabbccddeef0` would pass
|
|
28
|
+
// STORAGE_KEY_PATTERN and trigger an unnecessary existsSync probe.
|
|
29
|
+
readdir: (p) => readdirSync(p, { withFileTypes: true })
|
|
30
|
+
.filter((e) => e.isDirectory())
|
|
31
|
+
.map((e) => e.name),
|
|
32
|
+
exists: (p) => existsSync(p),
|
|
33
|
+
homedir,
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Find every storageKey that owns a given sessionId by scanning the AO
|
|
37
|
+
* base directory for projects whose `sessions/{sessionId}` file exists.
|
|
38
|
+
*
|
|
39
|
+
* This is the authoritative disambiguation step: tmux names alone are
|
|
40
|
+
* ambiguous when storageKey can take either the bare-hash or wrapped
|
|
41
|
+
* `{hash}-{projectName}` form. The on-disk session record is unique per
|
|
42
|
+
* storageKey so it tells us exactly which tmux name to expect.
|
|
43
|
+
*
|
|
44
|
+
* Returns all candidates (not just the first). Multiple projects can
|
|
45
|
+
* share a sessionId like `app-1`, and the caller must probe each until
|
|
46
|
+
* it finds a live tmux session — otherwise a stale metadata dir from
|
|
47
|
+
* one project could shadow the live session of another.
|
|
48
|
+
*/
|
|
49
|
+
function findStorageKeysForSession(sessionId, fs, projectId) {
|
|
50
|
+
const aoBase = join(fs.homedir(), ".agent-orchestrator");
|
|
51
|
+
let entries;
|
|
52
|
+
try {
|
|
53
|
+
entries = fs.readdir(aoBase);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
const matches = [];
|
|
59
|
+
const projectMatches = [];
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
if (!STORAGE_KEY_PATTERN.test(entry))
|
|
62
|
+
continue;
|
|
63
|
+
const sessionFile = join(aoBase, entry, "sessions", sessionId);
|
|
64
|
+
if (fs.exists(sessionFile)) {
|
|
65
|
+
const unwrappedProjectId = entry.slice(13); // Strip "{hash}-" prefix when present.
|
|
66
|
+
if (projectId && (entry === projectId || unwrappedProjectId === projectId)) {
|
|
67
|
+
projectMatches.push(entry);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
matches.push(entry);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return [...projectMatches, ...matches];
|
|
75
|
+
}
|
|
12
76
|
/**
|
|
13
77
|
* Validate a session ID format.
|
|
14
78
|
* Prevents path traversal, shell injection, and other attacks.
|
|
@@ -45,20 +109,29 @@ export function findTmux(execFn = execFileSync) {
|
|
|
45
109
|
/**
|
|
46
110
|
* Resolve a user-facing session ID to its actual tmux session name.
|
|
47
111
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
112
|
+
* ao-core names tmux sessions as `{storageKey}-{sessionId}`, where
|
|
113
|
+
* storageKey is either `{12-hex}` (bare hash) or `{12-hex}-{projectName}`
|
|
114
|
+
* (legacy wrapped format). This function:
|
|
50
115
|
*
|
|
51
|
-
* 1. Tries exact match
|
|
116
|
+
* 1. Tries exact match using tmux's `=` prefix syntax to prevent
|
|
52
117
|
* prefix matching (where "ao-1" would incorrectly match "ao-15").
|
|
53
|
-
* 2.
|
|
54
|
-
*
|
|
118
|
+
* 2. Looks up the storageKey owning this sessionId on disk (under
|
|
119
|
+
* `~/.agent-orchestrator/{storageKey}/sessions/{sessionId}`) and asks
|
|
120
|
+
* tmux whether the exact `{storageKey}-{sessionId}` session exists.
|
|
121
|
+
* The on-disk check is authoritative — it avoids ambiguous suffix
|
|
122
|
+
* matches where a bare session like `{hash}-my-app-1` could be
|
|
123
|
+
* mistaken for a lookup of `app-1`.
|
|
124
|
+
* 3. Falls back to listing sessions and matching a hash-prefixed name
|
|
125
|
+
* whose remainder equals the sessionId (bare-hash only), so behavior
|
|
126
|
+
* stays correct even if the on-disk session record is absent.
|
|
55
127
|
*
|
|
56
128
|
* @param sessionId - User-facing session ID (e.g., "ao-15")
|
|
57
129
|
* @param tmuxPath - Full path to tmux binary
|
|
58
130
|
* @param execFn - Injectable execFileSync for testing. Defaults to child_process.execFileSync.
|
|
131
|
+
* @param fs - Injectable filesystem adapter for testing.
|
|
59
132
|
* @returns The actual tmux session name, or null if not found
|
|
60
133
|
*/
|
|
61
|
-
export function resolveTmuxSession(sessionId, tmuxPath, execFn = execFileSync) {
|
|
134
|
+
export function resolveTmuxSession(sessionId, tmuxPath, execFn = execFileSync, fs = defaultFs, projectId) {
|
|
62
135
|
// Try exact match first using = prefix for exact matching (e.g., "ao-orchestrator")
|
|
63
136
|
// Without =, tmux uses prefix matching: "ao-1" would match "ao-15"
|
|
64
137
|
try {
|
|
@@ -68,9 +141,26 @@ export function resolveTmuxSession(sessionId, tmuxPath, execFn = execFileSync) {
|
|
|
68
141
|
catch {
|
|
69
142
|
// Not an exact match
|
|
70
143
|
}
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
144
|
+
// Authoritative path: find candidate storageKeys on disk, then verify
|
|
145
|
+
// each exact tmux session name with has-session. This is unambiguous
|
|
146
|
+
// even when the storageKey is wrapped (`{hash}-{projectName}`). Walk
|
|
147
|
+
// every candidate so a stale metadata dir in one project can't shadow
|
|
148
|
+
// the live session of another project with the same sessionId.
|
|
149
|
+
for (const storageKey of findStorageKeysForSession(sessionId, fs, projectId)) {
|
|
150
|
+
const tmuxName = `${storageKey}-${sessionId}`;
|
|
151
|
+
try {
|
|
152
|
+
execFn(tmuxPath, ["has-session", "-t", `=${tmuxName}`], { timeout: 5000 });
|
|
153
|
+
return tmuxName;
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// Session dir exists but tmux session doesn't — try next candidate
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Fallback: list sessions and match the bare-hash form only. We
|
|
160
|
+
// intentionally do NOT match by trailing suffix here — that would cause
|
|
161
|
+
// `app-1` to falsely resolve a distinct session `{hash}-my-app-1`. If a
|
|
162
|
+
// wrapped-storageKey session isn't findable on disk above, it's safer
|
|
163
|
+
// to return null than to guess.
|
|
74
164
|
try {
|
|
75
165
|
const output = execFn(tmuxPath, ["list-sessions", "-F", "#{session_name}"], {
|
|
76
166
|
timeout: 5000,
|
package/next.config.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { fileURLToPath } from "url";
|
|
3
|
+
|
|
4
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
|
|
1
6
|
/** @type {import('next').NextConfig} */
|
|
2
7
|
const nextConfig = {
|
|
8
|
+
outputFileTracingRoot: path.join(__dirname, "../.."),
|
|
3
9
|
transpilePackages: [
|
|
4
10
|
"@aoagents/ao-core",
|
|
5
11
|
"@aoagents/ao-plugin-agent-claude-code",
|