ompchamber 3.1.1 → 3.3.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/dist/client/index.html +1 -1
- package/dist/client/index.js +306 -298
- package/dist/client/static/js/index-bsxxt89j.css +1 -0
- package/dist/client/static/js/{index-qhzsybkn.js → index-qkt4z7x0.js} +658 -658
- package/package.json +4 -3
- package/public/sw.js +54 -5
- package/src/cli/lib/output.js +15 -8
- package/src/client/components/common/Modal.tsx +14 -4
- package/src/client/components/{layout/session-sidebar/SubagentList.tsx → common/subagent-list/index.tsx} +8 -10
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +6 -30
- package/src/client/components/layout/session-sidebar/Toolbar.tsx +11 -5
- package/src/client/components/layout/session-sidebar/index.tsx +4 -2
- package/src/client/components/mobile/LayoutWrapper.tsx +6 -0
- package/src/client/components/mobile/SessionSidebar.tsx +4 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +27 -8
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +67 -29
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.test.ts +84 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +59 -12
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
- package/src/client/components/settings/Modal.tsx +45 -23
- package/src/client/components/settings/SettingsSidebarList.tsx +1 -1
- package/src/client/components/settings/categories/AgentSettings.tsx +45 -28
- package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
- package/src/client/components/settings/categories/CommandSettings.tsx +45 -28
- package/src/client/components/settings/categories/McpSettings.tsx +48 -31
- package/src/client/components/settings/categories/ProjectSettings.tsx +34 -19
- package/src/client/components/settings/categories/ProviderSettings.tsx +77 -53
- package/src/client/components/settings/categories/SkillSettings.tsx +34 -17
- package/src/client/components/settings/categories/UsageSettings.tsx +53 -39
- package/src/client/components/settings/categories/appearance-settings/EditorFontSection.tsx +78 -0
- package/src/client/components/settings/categories/behavior-settings/Editor.tsx +7 -7
- package/src/client/components/settings/categories/mcp-settings/ImportModal.tsx +1 -1
- package/src/client/components/settings/categories/mcp-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/project-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/AuthSection.tsx +8 -3
- package/src/client/components/settings/categories/provider-settings/DeleteProviderModal.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/ModelsList.tsx +9 -4
- package/src/client/components/settings/categories/provider-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/skill-catalog/AddSourceModal.tsx +1 -1
- package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/usage-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/master-detail/index.tsx +68 -0
- package/src/client/components/workspace/SearchPanel.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +7 -1
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +19 -12
- package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/index.tsx +1 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +2 -1
- package/src/client/components/workspace/diff-panel/SplitView.tsx +31 -5
- package/src/client/components/workspace/diff-panel/Toolbar.tsx +77 -6
- package/src/client/components/workspace/diff-panel/UnifiedView.tsx +18 -5
- package/src/client/components/workspace/diff-panel/index.tsx +36 -7
- package/src/client/components/workspace/editor/Toolbar.tsx +11 -4
- package/src/client/components/workspace/editor/index.tsx +15 -7
- package/src/client/components/workspace/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
- package/src/client/components/workspace/terminal-panel/KeyBar.tsx +194 -0
- package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +24 -0
- package/src/client/components/workspace/terminal-panel/index.tsx +98 -3
- package/src/client/components/workspace/terminal-panel/mount.ts +47 -0
- package/src/client/data/theme/terminal.ts +25 -8
- package/src/client/hooks/chat/omp/session-list.tsx +31 -1
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +7 -1
- package/src/client/hooks/chat/subagent/index.ts +37 -11
- package/src/client/hooks/editor/font.ts +53 -0
- package/src/client/hooks/editor/use-file-editor.ts +1 -1
- package/src/client/hooks/settings/master-detail.ts +33 -0
- package/src/client/hooks/ui/clipboard.ts +17 -0
- package/src/client/hooks/ui/keyboard-inset.test.ts +46 -0
- package/src/client/hooks/ui/keyboard-inset.ts +113 -0
- package/src/client/hooks/ui/text-overflow.test.ts +152 -0
- package/src/client/hooks/ui/text-overflow.ts +78 -0
- package/src/client/hooks/ui/touch-device.ts +46 -0
- package/src/client/hooks/workspace/expanded-sessions.ts +62 -0
- package/src/client/main.tsx +1 -0
- package/src/server/index.ts +4 -3
- package/src/server/lib/assets/conditional.server.test.ts +74 -0
- package/src/server/lib/assets/conditional.server.ts +54 -0
- package/src/server/lib/assets/dev-assets.server.test.ts +44 -0
- package/src/server/lib/assets/dev-assets.server.ts +121 -0
- package/src/server/lib/assets/fonts.server.ts +9 -4
- package/src/server/lib/bundler/build-errors.server.ts +136 -0
- package/src/server/lib/bundler/build-errors.test.ts +103 -0
- package/src/server/lib/bundler/dependencies.test.ts +134 -0
- package/src/server/lib/db/client.ts +5 -1
- package/src/server/lib/fs/git-diff.ts +34 -10
- package/src/server/lib/fs/git-repos.ts +81 -0
- package/src/server/lib/lifecycle/flock.ts +199 -0
- package/src/server/lib/lifecycle/identity.ts +5 -1
- package/src/server/lib/lifecycle/listener.ts +29 -0
- package/src/server/lib/lifecycle/port-guard.ts +18 -3
- package/src/server/lib/lifecycle/proc/darwin.ts +52 -17
- package/src/server/lib/lifecycle/proc/linux.ts +29 -7
- package/src/server/lib/lifecycle/proc/ps.ts +7 -0
- package/src/server/lib/lifecycle/proc/types.ts +17 -0
- package/src/server/lib/lifecycle/proc/win32.ts +5 -0
- package/src/server/lib/observer-ws.ts +150 -0
- package/src/server/lib/omp/rpc/constants.ts +9 -0
- package/src/server/lib/omp/rpc/subagent-liveness.ts +2 -12
- package/src/server/lib/omp/session/file-scan.ts +7 -1
- package/src/server/lib/omp/session/files.ts +95 -96
- package/src/server/lib/omp/session/locator.ts +18 -8
- package/src/server/lib/omp/session/messages.ts +7 -7
- package/src/server/lib/omp/session/scan.ts +108 -0
- package/src/server/lib/omp/session/stats.ts +50 -50
- package/src/server/lib/omp/session/telemetry/scan.ts +89 -7
- package/src/server/lib/omp/session/telemetry-raw.ts +24 -8
- package/src/server/lib/omp/session/usage/aggregate.ts +170 -55
- package/src/server/lib/terminal/runtime.server.ts +3 -0
- package/src/server/lib/terminal/session.server.ts +30 -3
- package/src/server/plugins/shell.server.ts +52 -13
- package/src/server/plugins/ssr.ts +43 -7
- package/src/server/plugins/static.ts +51 -18
- package/src/server/routes/agent/command.test.ts +69 -0
- package/src/server/routes/agent/command.ts +14 -0
- package/src/server/routes/agent/ws.test.ts +159 -0
- package/src/server/routes/agent/ws.ts +22 -97
- package/src/server/routes/btw/ws.ts +22 -95
- package/src/server/routes/fs/git.ts +61 -70
- package/src/server/routes/omp/session-stats.ts +1 -1
- package/src/server/routes/telemetry/tokens.ts +6 -4
- package/src/server/routes/terminal/run.ts +4 -8
- package/src/server/routes/terminal/ws.ts +24 -2
- package/src/shared/lib/chat/omp/agent-events.test.ts +33 -1
- package/src/shared/lib/chat/omp/agent-events.ts +10 -0
- package/src/shared/lib/code/editor/typography.test.ts +93 -0
- package/src/shared/lib/code/editor/typography.ts +86 -0
- package/src/shared/lib/code/language.ts +146 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +2 -1
- package/src/shared/lib/code/syntax-highlight.ts +0 -122
- package/src/shared/lib/fonts/NOTICE.md +44 -0
- package/src/shared/lib/fonts/nerd-symbols.css +55 -0
- package/src/shared/lib/fonts/nerd-symbols.test.ts +134 -0
- package/src/shared/lib/fonts/symbols-nerd-font-mono.woff2 +0 -0
- package/src/shared/lib/fs/diff-parser.test.ts +5 -5
- package/src/shared/lib/fs/diff-parser.ts +67 -42
- package/src/shared/lib/omp/subagent/parse.test.ts +88 -0
- package/src/shared/lib/omp/subagent/parse.ts +17 -0
- package/src/shared/lib/omp/subagent/transcript-client.test.ts +100 -0
- package/src/shared/lib/omp/subagent/transcript-client.ts +17 -1
- package/src/shared/lib/settings/diff.test.ts +1 -1
- package/src/shared/lib/workspace/terminal/keys.test.ts +189 -0
- package/src/shared/lib/workspace/terminal/keys.ts +225 -0
- package/src/shared/types/omp/subagent.ts +6 -0
- package/src/shared/types/settings/state.ts +7 -16
- package/dist/client/static/js/index-5yfre1kw.css +0 -1
|
@@ -1,26 +1,47 @@
|
|
|
1
1
|
import { Elysia } from 'elysia';
|
|
2
2
|
import { getDb } from '@/server/db.server';
|
|
3
3
|
import { tryServeStatic } from '@/server/plugins/static';
|
|
4
|
-
import { renderShell } from '@/server/plugins/shell.server';
|
|
4
|
+
import { renderShell, SHELL_MARKER } from '@/server/plugins/shell.server';
|
|
5
|
+
import {
|
|
6
|
+
DEV_ASSETS_ENABLED,
|
|
7
|
+
devAssetUpstreamPath,
|
|
8
|
+
rewriteDevAssetUrls,
|
|
9
|
+
serveDevAsset,
|
|
10
|
+
} from '@/server/lib/assets/dev-assets.server';
|
|
11
|
+
import { listenerUrl } from '@/server/lib/lifecycle/listener';
|
|
5
12
|
import { FONT_STYLESHEET_ROUTE } from '@/server/lib/assets/font-css.server';
|
|
6
13
|
import { resolveTheme } from '@/shared/lib/theme/catalog';
|
|
7
14
|
import { THEME_STYLE_ELEMENT_ID, themeStyleSheet } from '@/shared/lib/theme/css';
|
|
8
15
|
|
|
16
|
+
/** HTML-escape a string for interpolation into the failure page's `<pre>`. */
|
|
17
|
+
function escapeHtmlText(value: string): string {
|
|
18
|
+
return value
|
|
19
|
+
.replace(/&/g, '&')
|
|
20
|
+
.replace(/</g, '<')
|
|
21
|
+
.replace(/>/g, '>')
|
|
22
|
+
.replace(/"/g, '"');
|
|
23
|
+
}
|
|
24
|
+
|
|
9
25
|
/**
|
|
10
26
|
* Actionable message instead of a bare 500 when the shell cannot be rendered.
|
|
11
27
|
*
|
|
12
28
|
* The shell is a Bun HTML route now, not a file on disk, so "not found" is no
|
|
13
29
|
* longer the failure mode — a bundle error is, and Bun renders its own error
|
|
14
|
-
* page for that
|
|
15
|
-
*
|
|
30
|
+
* page for that, with the file and line locked inside a payload only the
|
|
31
|
+
* browser can decode. `plugins/shell.server.ts` rebuilds the entrypoint to get
|
|
32
|
+
* those facts in readable form and passes them here as the reason, so this page
|
|
33
|
+
* names the edit that broke the bundle rather than the status it produced.
|
|
16
34
|
*/
|
|
17
35
|
function shellUnavailableResponse(reason: string): Response {
|
|
36
|
+
// Escaped, not interpolated raw: the reason carries the failed build's own
|
|
37
|
+
// source lines, which are arbitrary code — a `<` in one would otherwise
|
|
38
|
+
// truncate the page at the first tag.
|
|
18
39
|
return new Response(
|
|
19
40
|
`<!doctype html><meta charset="utf-8"><title>OMPChamber</title>
|
|
20
41
|
<body style="font:14px/1.6 ui-monospace,monospace;padding:2rem">
|
|
21
42
|
<h1 style="font-size:1.1rem">Shell not available</h1>
|
|
22
43
|
<p>The server could not render its HTML shell.</p>
|
|
23
|
-
<pre style="background:#f4f1ea;padding:1rem;border-radius:6px">${reason}</pre>
|
|
44
|
+
<pre style="background:#f4f1ea;padding:1rem;border-radius:6px">${escapeHtmlText(reason)}</pre>
|
|
24
45
|
</body>`,
|
|
25
46
|
{ status: 503, headers: HTML_HEADERS },
|
|
26
47
|
);
|
|
@@ -83,7 +104,17 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
|
|
|
83
104
|
});
|
|
84
105
|
}
|
|
85
106
|
|
|
86
|
-
|
|
107
|
+
// The dev asset proxy, before the static lookup: its prefix is this server's
|
|
108
|
+
// own, and Bun's table never sees it. See `lib/assets/dev-assets.server`.
|
|
109
|
+
if (DEV_ASSETS_ENABLED && devAssetUpstreamPath(pathname)) {
|
|
110
|
+
const base = listenerUrl();
|
|
111
|
+
if (base) {
|
|
112
|
+
const proxied = await serveDevAsset(request, pathname, base);
|
|
113
|
+
if (proxied) return proxied;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const asset = await tryServeStatic(pathname, request);
|
|
87
118
|
if (asset) return asset;
|
|
88
119
|
|
|
89
120
|
const settings = await readSettings();
|
|
@@ -110,13 +141,18 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
|
|
|
110
141
|
// loader and fail the same way.
|
|
111
142
|
const fontLink = `<link rel="stylesheet" href="${FONT_STYLESHEET_ROUTE}">`;
|
|
112
143
|
|
|
144
|
+
// Bun's dev asset URLs move to this server's prefix so the proxy above can add
|
|
145
|
+
// the caching headers Bun omits. Rewritten BEFORE the bootstrap is injected,
|
|
146
|
+
// so a `/_bun/` string inside the settings payload is left alone.
|
|
147
|
+
const shellHtml = DEV_ASSETS_ENABLED ? rewriteDevAssetUrls(rendered.html) : rendered.html;
|
|
148
|
+
|
|
113
149
|
return new Response(
|
|
114
|
-
|
|
150
|
+
shellHtml
|
|
115
151
|
.replace('data-theme="paper"', `data-theme="${theme.id}"`)
|
|
116
152
|
.replace('data-theme-variant="light"', `data-theme-variant="${theme.variant}"`)
|
|
117
153
|
.replace('<meta name="theme-color" content="#faf8f3" />', `<meta name="theme-color" content="${theme.canvas}" />`)
|
|
118
154
|
.replace('<!--app-head-->', themeStyle + fontLink)
|
|
119
|
-
.replace(
|
|
155
|
+
.replace(SHELL_MARKER, `<script>window.__OMP_BOOTSTRAP__=${bootstrap}</script>`),
|
|
120
156
|
{ headers: HTML_HEADERS },
|
|
121
157
|
);
|
|
122
158
|
});
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { extname, join, normalize, resolve, sep } from 'path';
|
|
2
2
|
|
|
3
|
+
import { notModified } from '@/server/lib/assets/conditional.server';
|
|
3
4
|
import { FONT_STYLESHEET_ROUTE, serveFontFile, serveFontStylesheet } from '@/server/lib/assets/font-css.server';
|
|
4
5
|
import { FONT_ROUTE_PREFIX, packageRoot } from '@/server/lib/assets/fonts.server';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* File serving for `public/` and the font routes.
|
|
8
9
|
*
|
|
9
|
-
* The client bundle is NOT served from here
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* The client bundle is NOT served from here. Bun's own routing owns the assets
|
|
11
|
+
* it emits — `/_bun/asset/*` and `/_bun/client/*` in development, `/chunk-*` in
|
|
12
|
+
* production — and answers them from its bundle table before a request reaches
|
|
13
|
+
* Elysia. In development those responses are reached a second way, through
|
|
14
|
+
* `lib/assets/dev-assets.server.ts`: Bun's table wins for the paths it declares,
|
|
15
|
+
* so the shell points at `/_dev-assets/*` and that module fetches the bytes back
|
|
16
|
+
* from the listener to add the `Cache-Control` and `ETag`/`304` handling Bun's
|
|
17
|
+
* dev asset routes lack. `dist/client` no longer exists, so the `/static/`
|
|
13
18
|
* branch and its content-hashed caching went with it.
|
|
14
19
|
*
|
|
15
20
|
* Hand-rolled rather than `@elysiajs/static`: that plugin registers its own
|
|
@@ -61,7 +66,17 @@ function resolveWithin(root: string, pathname: string): string | null {
|
|
|
61
66
|
return candidate;
|
|
62
67
|
}
|
|
63
68
|
|
|
64
|
-
|
|
69
|
+
/**
|
|
70
|
+
* The file's own bytes, or null when it cannot be read.
|
|
71
|
+
*
|
|
72
|
+
* `If-None-Match` is answered from a weak validator derived from the file's
|
|
73
|
+
* size and mtime. A content hash would be stronger, but it would mean reading
|
|
74
|
+
* every asset on every request to compute it — the opposite of the point.
|
|
75
|
+
* Size+mtime is what `plugins/static.ts` can produce from the `stat` it already
|
|
76
|
+
* performs, and a wrong `304` from it needs both a same-size edit and a
|
|
77
|
+
* preserved mtime, which is not something a build or an editor does.
|
|
78
|
+
*/
|
|
79
|
+
async function serveFile(filePath: string, cacheControl: string, request?: Request): Promise<Response | null> {
|
|
65
80
|
let stat;
|
|
66
81
|
try {
|
|
67
82
|
stat = await Bun.file(filePath).stat();
|
|
@@ -71,14 +86,24 @@ async function serveFile(filePath: string, cacheControl: string): Promise<Respon
|
|
|
71
86
|
if (!stat.isFile()) return null;
|
|
72
87
|
|
|
73
88
|
const type = MIME[extname(filePath).toLowerCase()] ?? 'application/octet-stream';
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
89
|
+
const lastModified = stat.mtime.toUTCString();
|
|
90
|
+
// Weak on purpose: the validator identifies the file as the same version, and
|
|
91
|
+
// byte equality is not what it promises.
|
|
92
|
+
const etag = `W/"${stat.size}-${Math.floor(stat.mtimeMs / 1000)}"`;
|
|
93
|
+
const headers = {
|
|
94
|
+
'content-type': type,
|
|
95
|
+
'cache-control': cacheControl,
|
|
96
|
+
'last-modified': lastModified,
|
|
97
|
+
etag,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
if (request && notModified(request, etag, stat.mtimeMs)) {
|
|
101
|
+
// No `content-length`: a 304 describes the validator, not a body.
|
|
102
|
+
return new Response(null, { status: 304, headers });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return new Response(Bun.file(filePath).stream(), {
|
|
106
|
+
headers: { ...headers, 'content-length': String(stat.size) },
|
|
82
107
|
});
|
|
83
108
|
}
|
|
84
109
|
|
|
@@ -90,18 +115,22 @@ async function serveFile(filePath: string, cacheControl: string): Promise<Respon
|
|
|
90
115
|
* going through the SSR route that injects the theme and bootstrap, and the
|
|
91
116
|
* cache stays short because these filenames carry no content hash.
|
|
92
117
|
*/
|
|
93
|
-
export async function servePublicAsset(
|
|
118
|
+
export async function servePublicAsset(
|
|
119
|
+
pathname: string,
|
|
120
|
+
roots: readonly string[],
|
|
121
|
+
request?: Request,
|
|
122
|
+
): Promise<Response | null> {
|
|
94
123
|
for (const root of roots) {
|
|
95
124
|
const filePath = resolveWithin(root, pathname);
|
|
96
125
|
if (!filePath || filePath.endsWith('.html')) continue;
|
|
97
|
-
const response = await serveFile(filePath, SHORT);
|
|
126
|
+
const response = await serveFile(filePath, SHORT, request);
|
|
98
127
|
if (response) return response;
|
|
99
128
|
}
|
|
100
129
|
return null;
|
|
101
130
|
}
|
|
102
131
|
|
|
103
132
|
/** A file for `pathname`, or null when the request should reach the app. */
|
|
104
|
-
export async function tryServeStatic(pathname: string): Promise<Response | null> {
|
|
133
|
+
export async function tryServeStatic(pathname: string, request?: Request): Promise<Response | null> {
|
|
105
134
|
// The production bundle's own assets. `scripts/build-client.ts` emits the
|
|
106
135
|
// server into `dist/client` with its chunks under `dist/client/static`, and
|
|
107
136
|
// the generated HTML references them as `./static/...` — a page URL of `/`
|
|
@@ -110,7 +139,7 @@ export async function tryServeStatic(pathname: string): Promise<Response | null>
|
|
|
110
139
|
// `/_bun/client/*`) and never reach here.
|
|
111
140
|
if (pathname.startsWith('/static/')) {
|
|
112
141
|
const filePath = resolveWithin(CLIENT_ROOT, pathname);
|
|
113
|
-
return filePath ? serveFile(filePath, ASSET_CACHE) : null;
|
|
142
|
+
return filePath ? serveFile(filePath, ASSET_CACHE, request) : null;
|
|
114
143
|
}
|
|
115
144
|
// The extracted `@font-face` stylesheet and the files it names. Bun's CSS
|
|
116
145
|
// loader cannot carry either — every local `url()` is resolved, so a bundled
|
|
@@ -118,9 +147,13 @@ export async function tryServeStatic(pathname: string): Promise<Response | null>
|
|
|
118
147
|
// than emitted. Both live outside `public/`: the stylesheet is assembled from
|
|
119
148
|
// the faces `lib/bundler/css.ts` wrote, and the files come from the installed
|
|
120
149
|
// packages.
|
|
150
|
+
//
|
|
151
|
+
// Neither is given a conditional here: the stylesheet is `no-store` (nothing
|
|
152
|
+
// is ever stored to revalidate) and a font file is `immutable` (the browser
|
|
153
|
+
// does not ask again). A validator on either would be dead code.
|
|
121
154
|
if (pathname === FONT_STYLESHEET_ROUTE) return serveFontStylesheet();
|
|
122
155
|
if (pathname.startsWith(FONT_ROUTE_PREFIX)) {
|
|
123
156
|
return serveFontFile(decodeURIComponent(pathname.slice(FONT_ROUTE_PREFIX.length)));
|
|
124
157
|
}
|
|
125
|
-
return servePublicAsset(pathname, [PUBLIC_ROOT]);
|
|
158
|
+
return servePublicAsset(pathname, [PUBLIC_ROOT], request);
|
|
126
159
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The spawn guard on POST /api/agent/:sessionId.
|
|
8
|
+
*
|
|
9
|
+
* This route is the chamber's ONLY lazy-spawn path: a command posted for a
|
|
10
|
+
* session it does not manage starts an `omp` child for it. That is right for a
|
|
11
|
+
* prompt — the user asked for work — and wrong for a read. The sidebar renders
|
|
12
|
+
* a subagent roster row for every session in the list, and the roster probes
|
|
13
|
+
* `get_subagents` to seed itself, so an unguarded route booted a whole omp
|
|
14
|
+
* process per finished session merely LOOKED at (measured: the process count
|
|
15
|
+
* grew on a session whose liveness probe had just answered `running: false`).
|
|
16
|
+
*
|
|
17
|
+
* Both observer-only commands have RPC-free on-disk equivalents, which is what
|
|
18
|
+
* a finished session is served from, so refusing them for a dead session costs
|
|
19
|
+
* the client nothing.
|
|
20
|
+
*
|
|
21
|
+
* No module is mocked. The assertions are about which response comes back
|
|
22
|
+
* BEFORE the spawn path is reached, so the real registry (empty for these ids)
|
|
23
|
+
* and the real on-disk resolver are exactly the inputs that matter. The
|
|
24
|
+
* "prompt" case proves the guard is not a blanket refusal: it reaches the
|
|
25
|
+
* resolver and 404s for an id with no session file — a spawn was attempted.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, expect, test } from 'bun:test';
|
|
29
|
+
import { sendCommand } from '@/server/routes/agent/command';
|
|
30
|
+
|
|
31
|
+
/** Ids that no chamber instance manages and no session file backs. */
|
|
32
|
+
const DEAD = 'spawn-guard-dead-session';
|
|
33
|
+
const UNKNOWN = 'spawn-guard-unknown-session';
|
|
34
|
+
|
|
35
|
+
function post(sessionId: string, body: Record<string, unknown>): Promise<Response> {
|
|
36
|
+
return sendCommand({
|
|
37
|
+
params: { sessionId },
|
|
38
|
+
request: new Request(`http://localhost/api/agent/${sessionId}`, {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: { 'Content-Type': 'application/json' },
|
|
41
|
+
body: JSON.stringify(body),
|
|
42
|
+
}),
|
|
43
|
+
} as never) as Promise<Response>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function payload(response: Response): Promise<Record<string, unknown>> {
|
|
47
|
+
return (await response.json()) as Record<string, unknown>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('agent command spawn guard', () => {
|
|
51
|
+
test('refuses a roster snapshot for a session that is not running', async () => {
|
|
52
|
+
const response = await post(DEAD, { type: 'get_subagents' });
|
|
53
|
+
expect(response.status).toBe(409);
|
|
54
|
+
expect(await payload(response)).toMatchObject({ code: 'session_not_running' });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('refuses a transcript page for a session that is not running', async () => {
|
|
58
|
+
const response = await post(DEAD, { type: 'get_subagent_messages', subagentId: 'child' });
|
|
59
|
+
expect(response.status).toBe(409);
|
|
60
|
+
expect(await payload(response)).toMatchObject({ code: 'session_not_running' });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('does not refuse a command that asks for work', async () => {
|
|
64
|
+
const response = await post(UNKNOWN, { type: 'prompt', message: 'hello' });
|
|
65
|
+
// It reached the spawn path and failed there (no session file on disk),
|
|
66
|
+
// which is the opposite of the guard's 409.
|
|
67
|
+
expect(response.status).not.toBe(409);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -5,6 +5,7 @@ import { WebRpcError, getRpcSession, resolveSpawnCwd, startRpcSession, type Agen
|
|
|
5
5
|
import { getSpawnApprovalMode, reconcileSpawnApprovalMode } from '@/server/lib/omp/rpc/session-registry';
|
|
6
6
|
import { isApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
7
7
|
import { loadPersistedAccessMode } from '@/shared/lib/omp/config/access-mode.server';
|
|
8
|
+
import { OBSERVER_ONLY_COMMANDS } from '@/server/lib/omp/rpc/constants';
|
|
8
9
|
import { rpcErrorResponse } from '@/server/lib/omp/rpc/errors';
|
|
9
10
|
|
|
10
11
|
// POST /api/agent/:sessionId — send a command to an existing session (or spawn
|
|
@@ -39,6 +40,19 @@ export async function sendCommand({ params, request }: ActionFunctionArgs) {
|
|
|
39
40
|
}
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
// Observer-only reads must never boot an omp child. This route is the spawn
|
|
44
|
+
// path, so a roster snapshot (`get_subagents`) or a transcript page
|
|
45
|
+
// (`get_subagent_messages`) posted for a session the chamber is not
|
|
46
|
+
// managing would start a whole process just to answer a read — measured:
|
|
47
|
+
// opening a FINISHED session's roster row grew the omp process count, and
|
|
48
|
+
// the sidebar renders those rows for every session in the list. Both reads
|
|
49
|
+
// have RPC-free on-disk equivalents (`GET /api/sessions/:id/subagents[/:sub]`),
|
|
50
|
+
// which is what a dead session is served from; the live registry is only
|
|
51
|
+
// consulted while a process exists to answer for.
|
|
52
|
+
if (OBSERVER_ONLY_COMMANDS.has(body.type)) {
|
|
53
|
+
return json({ error: 'Session is not managed by the chamber', code: 'session_not_running' }, { status: 409 });
|
|
54
|
+
}
|
|
55
|
+
|
|
42
56
|
const resolved = await resolveSessionPathOr404(sessionId);
|
|
43
57
|
if ('response' in resolved) return resolved.response;
|
|
44
58
|
const { filePath, recordedCwd } = resolved;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Regression test for the observer WebSocket routes' connection state.
|
|
8
|
+
*
|
|
9
|
+
* The bug this pins: `agent/ws.ts` and `btw/ws.ts` kept per-connection state in
|
|
10
|
+
* a `WeakMap` keyed by the `ws` object. Elysia's Bun adapter builds a NEW
|
|
11
|
+
* `ElysiaWS` wrapper for every callback and hands `pong` the RAW socket, so
|
|
12
|
+
* every lookup missed — the pong never cleared `awaitingPong`, the second
|
|
13
|
+
* heartbeat tore down a socket the client was still reading, and `close` leaked
|
|
14
|
+
* the interval and the session subscription for the life of the process.
|
|
15
|
+
*
|
|
16
|
+
* These assertions run through a real listener on purpose: the failure lived in
|
|
17
|
+
* the framework's callback identity, which a unit-level fake of `ws` cannot
|
|
18
|
+
* reproduce. That also makes the heartbeat a REAL timer here — the property
|
|
19
|
+
* under test is "the socket outlives N keepalive cycles", so the clock is the
|
|
20
|
+
* thing being measured. The interval is shrunk to 60ms to keep it cheap.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { afterAll, beforeAll, describe, expect, mock, test } from 'bun:test';
|
|
24
|
+
import { Elysia } from 'elysia';
|
|
25
|
+
|
|
26
|
+
/** Heartbeat for the test — several beats must fit inside a test timeout. */
|
|
27
|
+
const HEARTBEAT_MS = 60;
|
|
28
|
+
|
|
29
|
+
// The route reads the cadence constant at module scope, so the module is
|
|
30
|
+
// replaced BEFORE the route module is imported.
|
|
31
|
+
mock.module('@/shared/lib/workspace/refresh-cadence', () => ({
|
|
32
|
+
STREAM_HEARTBEAT_MS: HEARTBEAT_MS,
|
|
33
|
+
SIDEBAR_IDLE_REFRESH_MS: 30_000,
|
|
34
|
+
SIDEBAR_STREAM_POLL_MS: 8_000,
|
|
35
|
+
SIDEBAR_REVALIDATE_THROTTLE_MS: 1_000,
|
|
36
|
+
PANEL_REFRESH_MS: 5_000,
|
|
37
|
+
FILE_MUTATION_THROTTLE_MS: 500,
|
|
38
|
+
GIT_STATUS_POLL_MS: 15_000,
|
|
39
|
+
GIT_STATUS_EVENT_THROTTLE_MS: 1_000,
|
|
40
|
+
REPO_DISCOVERY_POLL_MS: 1_500,
|
|
41
|
+
TODO_REFRESH_EVENT_THROTTLE_MS: 400,
|
|
42
|
+
BROWSER_POLL_MS: 1_000,
|
|
43
|
+
SIDEBAR_DATA_TTL_MS: 4_000,
|
|
44
|
+
SESSION_META_RETRY_SCHEDULE_MS: [250],
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
const { agentWsRoutes } = await import('@/server/routes/agent/ws');
|
|
48
|
+
|
|
49
|
+
/** The live session the route subscribes to; records its own lifecycle. */
|
|
50
|
+
interface SessionStub {
|
|
51
|
+
isAlive: () => boolean;
|
|
52
|
+
onEvent: (listener: (event: unknown) => void) => () => void;
|
|
53
|
+
listeners: Set<(event: unknown) => void>;
|
|
54
|
+
unsubscribes: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function makeSession(): SessionStub {
|
|
58
|
+
const stub: SessionStub = {
|
|
59
|
+
isAlive: () => true,
|
|
60
|
+
listeners: new Set(),
|
|
61
|
+
unsubscribes: 0,
|
|
62
|
+
onEvent(listener) {
|
|
63
|
+
stub.listeners.add(listener);
|
|
64
|
+
return () => {
|
|
65
|
+
stub.unsubscribes += 1;
|
|
66
|
+
stub.listeners.delete(listener);
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
return stub;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let session = makeSession();
|
|
74
|
+
const app = new Elysia().use(agentWsRoutes);
|
|
75
|
+
let baseUrl = '';
|
|
76
|
+
|
|
77
|
+
beforeAll(async () => {
|
|
78
|
+
(globalThis as { __ompSessions?: unknown }).__ompSessions = {
|
|
79
|
+
get: (id: string) => (id === 'test-session' ? session : undefined),
|
|
80
|
+
};
|
|
81
|
+
await app.listen({ port: 0, hostname: '127.0.0.1' });
|
|
82
|
+
const port = (app.server as { port?: number } | null)?.port;
|
|
83
|
+
if (!port) throw new Error('test listener did not report a port');
|
|
84
|
+
baseUrl = `ws://127.0.0.1:${port}`;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterAll(async () => {
|
|
88
|
+
await app.stop();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
92
|
+
|
|
93
|
+
/** One client, its received frames, and the promises for its lifecycle. */
|
|
94
|
+
function connect(sessionId: string) {
|
|
95
|
+
const frames: unknown[] = [];
|
|
96
|
+
const client = new WebSocket(`${baseUrl}/api/agent/${sessionId}/ws`);
|
|
97
|
+
const opened = Promise.withResolvers<void>();
|
|
98
|
+
const closed = Promise.withResolvers<number>();
|
|
99
|
+
client.onopen = () => opened.resolve();
|
|
100
|
+
client.onmessage = (event) => {
|
|
101
|
+
try {
|
|
102
|
+
frames.push(JSON.parse(String(event.data)));
|
|
103
|
+
} catch {
|
|
104
|
+
// A non-JSON frame would be a protocol bug; assertions read `frames`.
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
client.onclose = (event) => closed.resolve(event.code);
|
|
108
|
+
return { client, frames, opened: opened.promise, closed: closed.promise };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
describe('agent observer WebSocket', () => {
|
|
112
|
+
test('survives many heartbeats instead of being torn down by its own keepalive', async () => {
|
|
113
|
+
session = makeSession();
|
|
114
|
+
const { client, closed } = connect('test-session');
|
|
115
|
+
// Five beats: the old code closed the socket at the second one.
|
|
116
|
+
await sleep(HEARTBEAT_MS * 5);
|
|
117
|
+
expect(client.readyState).toBe(WebSocket.OPEN);
|
|
118
|
+
client.close();
|
|
119
|
+
await closed;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('delivers the connected greeting and live session frames', async () => {
|
|
123
|
+
session = makeSession();
|
|
124
|
+
const { client, frames, opened, closed } = connect('test-session');
|
|
125
|
+
await opened;
|
|
126
|
+
for (const listener of session.listeners) listener({ type: 'agent_start' });
|
|
127
|
+
await sleep(30);
|
|
128
|
+
expect(frames).toEqual([
|
|
129
|
+
{ type: 'connected', sessionId: 'test-session' },
|
|
130
|
+
{ type: 'agent_start' },
|
|
131
|
+
]);
|
|
132
|
+
client.close();
|
|
133
|
+
await closed;
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('releases the session subscription when the client disconnects', async () => {
|
|
137
|
+
session = makeSession();
|
|
138
|
+
const { client, opened, closed } = connect('test-session');
|
|
139
|
+
await opened;
|
|
140
|
+
expect(session.listeners.size).toBe(1);
|
|
141
|
+
client.close();
|
|
142
|
+
await closed;
|
|
143
|
+
await sleep(30);
|
|
144
|
+
expect(session.unsubscribes).toBe(1);
|
|
145
|
+
expect(session.listeners.size).toBe(0);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('refuses a session the chamber does not manage', async () => {
|
|
149
|
+
const { opened, closed, client } = connect('unknown-session');
|
|
150
|
+
const outcome = await Promise.race([
|
|
151
|
+
opened.then(() => 'opened' as const),
|
|
152
|
+
closed.then(() => 'closed' as const),
|
|
153
|
+
sleep(400).then(() => 'timeout' as const),
|
|
154
|
+
]);
|
|
155
|
+
// The upgrade is rejected before `open` runs, so the client never opens.
|
|
156
|
+
expect(outcome).not.toBe('opened');
|
|
157
|
+
client.close();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Same frame vocabulary (JSON objects with a `type`, first frame `connected`),
|
|
11
11
|
* same backpressure policy (replaceable `message_update` frames collapse to the
|
|
12
|
-
* latest one while the client is behind) — the policy itself
|
|
12
|
+
* latest one while the client is behind) — the policy itself lives in
|
|
13
13
|
* `createEventCoalescer`, shared with the SSE route — but carried by one duplex
|
|
14
14
|
* socket: no per-event HTTP framing, no browser reconnect storm, and
|
|
15
15
|
* protocol-level ping/pong keepalive instead of a comment frame every 30s.
|
|
@@ -17,18 +17,18 @@
|
|
|
17
17
|
* Transport-only by design. It reads the live session registry off `globalThis`
|
|
18
18
|
* (the map the server build populates) and duck-types the wrapper, so the
|
|
19
19
|
* registry contract survives any HTTP framework owning the port.
|
|
20
|
+
*
|
|
21
|
+
* **Per-connection state lives on `ws.data`, not in a `WeakMap` keyed by the
|
|
22
|
+
* `ws` object.** Elysia's Bun adapter builds a fresh wrapper per callback and
|
|
23
|
+
* hands `pong` the raw socket, so a WeakMap lookup always missed: the pong
|
|
24
|
+
* never cleared `awaitingPong` and the second heartbeat closed a healthy
|
|
25
|
+
* socket at 60s, while `close` leaked the interval and the session
|
|
26
|
+
* subscription. `attachObserverStream` owns the lifecycle; see
|
|
27
|
+
* `@/server/lib/observer-ws` for the measurements.
|
|
20
28
|
*/
|
|
21
29
|
|
|
22
30
|
import { Elysia, t } from 'elysia';
|
|
23
|
-
import {
|
|
24
|
-
import { STREAM_HEARTBEAT_MS } from '@/shared/lib/workspace/refresh-cadence';
|
|
25
|
-
|
|
26
|
-
/** Coalesce `message_update` frames once this many bytes sit unsent. */
|
|
27
|
-
const HIGH_WATER_BYTES = 256 * 1024;
|
|
28
|
-
|
|
29
|
-
/** Longest a coalesced frame may wait before it is flushed anyway, so a burst
|
|
30
|
-
* that ends while the socket is still busy never strands the newest update. */
|
|
31
|
-
const FLUSH_DELAY_MS = 50;
|
|
31
|
+
import { attachObserverStream, createObserverState, type ObserverConnectionState } from '@/server/lib/observer-ws';
|
|
32
32
|
|
|
33
33
|
interface AgentEventSession {
|
|
34
34
|
isAlive?: () => boolean;
|
|
@@ -48,15 +48,8 @@ function findAgentSession(sessionId: string): AgentEventSession | null {
|
|
|
48
48
|
return session;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
unsubscribe: (() => void) | null;
|
|
54
|
-
heartbeat: ReturnType<typeof setInterval> | null;
|
|
55
|
-
awaitingPong: boolean;
|
|
56
|
-
coalescer: EventCoalescer | null;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const connections = new WeakMap<object, ConnectionState>();
|
|
51
|
+
/** Elysia's ws context, narrowed to the per-connection store this route adds. */
|
|
52
|
+
type AgentWsData = { params: { sessionId: string }; observer?: ObserverConnectionState };
|
|
60
53
|
|
|
61
54
|
export const agentWsRoutes = new Elysia({ prefix: '/api/agent' }).ws('/:sessionId/ws', {
|
|
62
55
|
params: t.Object({ sessionId: t.String() }),
|
|
@@ -68,81 +61,24 @@ export const agentWsRoutes = new Elysia({ prefix: '/api/agent' }).ws('/:sessionI
|
|
|
68
61
|
},
|
|
69
62
|
|
|
70
63
|
open(ws) {
|
|
71
|
-
const
|
|
64
|
+
const data = ws.data as unknown as AgentWsData;
|
|
65
|
+
const sessionId = data.params.sessionId;
|
|
72
66
|
const session = findAgentSession(sessionId);
|
|
73
67
|
if (!session) {
|
|
74
68
|
ws.close();
|
|
75
69
|
return;
|
|
76
70
|
}
|
|
77
71
|
|
|
78
|
-
const state
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
heartbeat: null,
|
|
82
|
-
awaitingPong: false,
|
|
83
|
-
coalescer: null,
|
|
84
|
-
};
|
|
85
|
-
connections.set(ws, state);
|
|
72
|
+
const state = createObserverState();
|
|
73
|
+
data.observer = state;
|
|
74
|
+
const { push } = attachObserverStream(state, ws);
|
|
86
75
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
state.closed = true;
|
|
90
|
-
state.coalescer?.dispose();
|
|
91
|
-
state.coalescer = null;
|
|
92
|
-
if (state.heartbeat !== null) {
|
|
93
|
-
clearInterval(state.heartbeat);
|
|
94
|
-
state.heartbeat = null;
|
|
95
|
-
}
|
|
96
|
-
if (state.unsubscribe) {
|
|
97
|
-
try {
|
|
98
|
-
state.unsubscribe();
|
|
99
|
-
} catch {
|
|
100
|
-
// Subscriber cleanup is best-effort.
|
|
101
|
-
}
|
|
102
|
-
state.unsubscribe = null;
|
|
103
|
-
}
|
|
104
|
-
try {
|
|
105
|
-
ws.close();
|
|
106
|
-
} catch {
|
|
107
|
-
// Already gone.
|
|
108
|
-
}
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
state.coalescer = createEventCoalescer({
|
|
112
|
-
send: (_event, data) => {
|
|
113
|
-
try {
|
|
114
|
-
ws.send(JSON.stringify(data));
|
|
115
|
-
} catch {
|
|
116
|
-
teardown();
|
|
117
|
-
}
|
|
118
|
-
},
|
|
119
|
-
isBackpressured: () => ((ws.raw as { bufferedAmount?: number }).bufferedAmount ?? 0) > HIGH_WATER_BYTES,
|
|
120
|
-
flushDelayMs: FLUSH_DELAY_MS,
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
// Protocol-level keepalive: a client that misses a full ping cycle is gone,
|
|
124
|
-
// and a live one answers without any application-level traffic.
|
|
125
|
-
state.heartbeat = setInterval(() => {
|
|
126
|
-
if (state.closed) return;
|
|
127
|
-
if (state.awaitingPong) {
|
|
128
|
-
teardown();
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
state.awaitingPong = true;
|
|
132
|
-
if (!state.coalescer?.flush()) return;
|
|
133
|
-
try {
|
|
134
|
-
(ws.raw as { ping?: () => void }).ping?.();
|
|
135
|
-
} catch {
|
|
136
|
-
teardown();
|
|
137
|
-
}
|
|
138
|
-
}, STREAM_HEARTBEAT_MS);
|
|
139
|
-
|
|
140
|
-
state.unsubscribe = session.onEvent((event) => state.coalescer?.push('', event));
|
|
141
|
-
state.coalescer.push('', { type: 'connected', sessionId });
|
|
76
|
+
state.unsubscribe = session.onEvent((event) => push('', event));
|
|
77
|
+
push('', { type: 'connected', sessionId });
|
|
142
78
|
},
|
|
143
79
|
|
|
144
80
|
pong(ws) {
|
|
145
|
-
const state =
|
|
81
|
+
const state = (ws.data as unknown as AgentWsData).observer;
|
|
146
82
|
if (state) state.awaitingPong = false;
|
|
147
83
|
},
|
|
148
84
|
|
|
@@ -151,18 +87,7 @@ export const agentWsRoutes = new Elysia({ prefix: '/api/agent' }).ws('/:sessionI
|
|
|
151
87
|
},
|
|
152
88
|
|
|
153
89
|
close(ws) {
|
|
154
|
-
const state =
|
|
155
|
-
if (
|
|
156
|
-
state.closed = true;
|
|
157
|
-
state.coalescer?.dispose();
|
|
158
|
-
state.coalescer = null;
|
|
159
|
-
if (state.heartbeat !== null) clearInterval(state.heartbeat);
|
|
160
|
-
if (state.unsubscribe) {
|
|
161
|
-
try {
|
|
162
|
-
state.unsubscribe();
|
|
163
|
-
} catch {
|
|
164
|
-
// Subscriber cleanup is best-effort.
|
|
165
|
-
}
|
|
166
|
-
}
|
|
90
|
+
const state = (ws.data as unknown as AgentWsData).observer;
|
|
91
|
+
if (state) state.teardown();
|
|
167
92
|
},
|
|
168
93
|
});
|