ompchamber 3.1.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -3
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +279 -280
- package/dist/client/static/js/index-01dv383b.css +1 -0
- package/dist/client/static/js/{index-8t70jhyj.js → index-1q6165r3.js} +641 -641
- package/package.json +1 -1
- package/src/cli/lib/args.js +4 -3
- package/src/cli/lib/commands/restart.js +7 -6
- package/src/cli/lib/commands/stop.js +38 -31
- package/src/cli/lib/commands/update.js +3 -2
- package/src/cli/lib/output.js +15 -8
- package/src/cli/lib/stop-scope.js +23 -0
- package/src/cli/lib/stop-scope.test.js +41 -0
- 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-session-sidebar/Item.tsx +66 -29
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +50 -12
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
- package/src/client/components/settings/Modal.tsx +35 -6
- 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/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/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 +2 -1
- 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/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
- 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/use-file-editor.ts +1 -1
- package/src/client/hooks/settings/master-detail.ts +33 -0
- package/src/client/hooks/workspace/expanded-sessions.ts +62 -0
- package/src/server/lib/bundler/css.test.ts +87 -0
- package/src/server/lib/bundler/css.ts +17 -2
- package/src/server/lib/bundler/nested-at-rules.test.ts +100 -0
- package/src/server/lib/bundler/nested-at-rules.ts +146 -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/launch-mode.test.ts +20 -1
- package/src/server/lib/lifecycle/launch-mode.ts +26 -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/lifecycle/restart.ts +9 -14
- 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/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/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/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/types/omp/subagent.ts +6 -0
- package/dist/client/static/js/index-n4wkqj7t.css +0 -1
- package/src/server/lib/lifecycle/restart.test.ts +0 -21
|
@@ -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
|
});
|
|
@@ -12,105 +12,43 @@
|
|
|
12
12
|
* A client that attaches replays nothing: the socket's first `btw_state` is
|
|
13
13
|
* the session's current history, so a reload lands on the same panel a live
|
|
14
14
|
* stream would have produced.
|
|
15
|
+
*
|
|
16
|
+
* **Per-connection state lives on `ws.data`, not in a `WeakMap` keyed by the
|
|
17
|
+
* `ws` object** — Elysia's Bun adapter hands `pong` the raw socket and a fresh
|
|
18
|
+
* wrapper to every other callback, so a WeakMap lookup always missed and the
|
|
19
|
+
* second heartbeat closed a healthy socket. See `@/server/lib/observer-ws`.
|
|
15
20
|
*/
|
|
16
21
|
|
|
17
22
|
import { Elysia, t } from 'elysia';
|
|
18
|
-
import {
|
|
19
|
-
import { STREAM_HEARTBEAT_MS } from '@/shared/lib/workspace/refresh-cadence';
|
|
23
|
+
import { attachObserverStream, createObserverState, type ObserverConnectionState } from '@/server/lib/observer-ws';
|
|
20
24
|
import { btwStateFor, subscribeBtw } from '@/server/lib/btw/registry.server';
|
|
21
25
|
import type { BtwFrame } from '@/shared/types';
|
|
22
26
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
/** Longest a coalesced frame may wait before it is flushed anyway. */
|
|
27
|
-
const FLUSH_DELAY_MS = 50;
|
|
28
|
-
|
|
29
|
-
interface ConnectionState {
|
|
30
|
-
closed: boolean;
|
|
31
|
-
unsubscribe: (() => void) | null;
|
|
32
|
-
heartbeat: ReturnType<typeof setInterval> | null;
|
|
33
|
-
awaitingPong: boolean;
|
|
34
|
-
coalescer: EventCoalescer | null;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const connections = new WeakMap<object, ConnectionState>();
|
|
27
|
+
/** Elysia's ws context, narrowed to the per-connection store this route adds. */
|
|
28
|
+
type BtwWsData = { params: { sessionId: string }; observer?: ObserverConnectionState };
|
|
38
29
|
|
|
39
30
|
export const btwWsRoutes = new Elysia({ prefix: '/api/btw' }).ws('/:sessionId/ws', {
|
|
40
31
|
params: t.Object({ sessionId: t.String() }),
|
|
41
32
|
|
|
42
33
|
open(ws) {
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (state.closed) return;
|
|
53
|
-
state.closed = true;
|
|
54
|
-
state.coalescer?.dispose();
|
|
55
|
-
state.coalescer = null;
|
|
56
|
-
if (state.heartbeat !== null) {
|
|
57
|
-
clearInterval(state.heartbeat);
|
|
58
|
-
state.heartbeat = null;
|
|
59
|
-
}
|
|
60
|
-
if (state.unsubscribe) {
|
|
61
|
-
try {
|
|
62
|
-
state.unsubscribe();
|
|
63
|
-
} catch {
|
|
64
|
-
// Subscriber cleanup is best-effort.
|
|
65
|
-
}
|
|
66
|
-
state.unsubscribe = null;
|
|
67
|
-
}
|
|
68
|
-
try {
|
|
69
|
-
ws.close();
|
|
70
|
-
} catch {
|
|
71
|
-
// Already gone.
|
|
72
|
-
}
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
state.coalescer = createEventCoalescer({
|
|
76
|
-
send: (_event, data) => {
|
|
77
|
-
try {
|
|
78
|
-
ws.send(JSON.stringify(data));
|
|
79
|
-
} catch {
|
|
80
|
-
teardown();
|
|
81
|
-
}
|
|
82
|
-
},
|
|
83
|
-
isBackpressured: () => (raw.bufferedAmount ?? 0) > HIGH_WATER_BYTES,
|
|
84
|
-
flushDelayMs: FLUSH_DELAY_MS,
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
state.heartbeat = setInterval(() => {
|
|
88
|
-
if (state.closed) return;
|
|
89
|
-
if (state.awaitingPong) {
|
|
90
|
-
teardown();
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
state.awaitingPong = true;
|
|
94
|
-
if (!state.coalescer?.flush()) return;
|
|
95
|
-
try {
|
|
96
|
-
raw.ping?.();
|
|
97
|
-
} catch {
|
|
98
|
-
teardown();
|
|
99
|
-
}
|
|
100
|
-
}, STREAM_HEARTBEAT_MS);
|
|
101
|
-
|
|
102
|
-
const push = (frame: BtwFrame) => state.coalescer?.push('', frame);
|
|
103
|
-
state.unsubscribe = subscribeBtw(sessionId, push);
|
|
104
|
-
push({ type: 'connected', sessionId });
|
|
34
|
+
const data = ws.data as unknown as BtwWsData;
|
|
35
|
+
const sessionId = data.params.sessionId;
|
|
36
|
+
const state = createObserverState();
|
|
37
|
+
data.observer = state;
|
|
38
|
+
const { push } = attachObserverStream(state, ws);
|
|
39
|
+
|
|
40
|
+
const send = (frame: BtwFrame) => push('', frame);
|
|
41
|
+
state.unsubscribe = subscribeBtw(sessionId, send);
|
|
42
|
+
send({ type: 'connected', sessionId });
|
|
105
43
|
void btwStateFor(sessionId)
|
|
106
|
-
.then((snapshot) =>
|
|
44
|
+
.then((snapshot) => send({ type: 'btw_state', state: snapshot }))
|
|
107
45
|
.catch((error: unknown) => {
|
|
108
|
-
|
|
46
|
+
send({ type: 'btw_error', topicId: null, message: error instanceof Error ? error.message : String(error) });
|
|
109
47
|
});
|
|
110
48
|
},
|
|
111
49
|
|
|
112
50
|
pong(ws) {
|
|
113
|
-
const state =
|
|
51
|
+
const state = (ws.data as unknown as BtwWsData).observer;
|
|
114
52
|
if (state) state.awaitingPong = false;
|
|
115
53
|
},
|
|
116
54
|
|
|
@@ -119,18 +57,7 @@ export const btwWsRoutes = new Elysia({ prefix: '/api/btw' }).ws('/:sessionId/ws
|
|
|
119
57
|
},
|
|
120
58
|
|
|
121
59
|
close(ws) {
|
|
122
|
-
const state =
|
|
123
|
-
if (
|
|
124
|
-
state.closed = true;
|
|
125
|
-
state.coalescer?.dispose();
|
|
126
|
-
state.coalescer = null;
|
|
127
|
-
if (state.heartbeat !== null) clearInterval(state.heartbeat);
|
|
128
|
-
if (state.unsubscribe) {
|
|
129
|
-
try {
|
|
130
|
-
state.unsubscribe();
|
|
131
|
-
} catch {
|
|
132
|
-
// Subscriber cleanup is best-effort.
|
|
133
|
-
}
|
|
134
|
-
}
|
|
60
|
+
const state = (ws.data as unknown as BtwWsData).observer;
|
|
61
|
+
if (state) state.teardown();
|
|
135
62
|
},
|
|
136
63
|
});
|
|
@@ -1,65 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `GET /api/fs/git` — the Source Control panel's data, and the file-diff /
|
|
8
|
+
* commit-history endpoints the diff panel and commit modal read.
|
|
9
|
+
*
|
|
10
|
+
* Repository discovery (`?reposOnly=1`) is a background walk over the scoped
|
|
11
|
+
* root; see `lib/fs/git-repos.ts`. This module owns the request shapes.
|
|
12
|
+
*/
|
|
13
|
+
|
|
1
14
|
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from '@/server/lib/remix-compat';
|
|
2
15
|
import fs from 'fs';
|
|
3
16
|
import path from 'path';
|
|
4
17
|
import { resolveRoot } from '@/server/lib/fs/root';
|
|
5
18
|
import { runShell } from '@/server/lib/fs/shell';
|
|
19
|
+
import { discoveredRepos, rescanRepos, startRepoScan } from '@/server/lib/fs/git-repos';
|
|
6
20
|
import { fetchFileDiff, fetchGitCommits } from '@/server/lib/fs/git-log';
|
|
7
21
|
import { fetchWorkingFileDiff } from '@/server/lib/fs/git-diff';
|
|
8
22
|
import { gitSyncCount, invalidateRemoteRefs, markRemoteRefsFresh, refreshRemoteRefs } from '@/server/lib/fs/git-sync';
|
|
9
23
|
|
|
10
|
-
const MAX_GIT_DEPTH = 8;
|
|
11
|
-
|
|
12
|
-
async function getRepos(rootDir: string): Promise<string[]> {
|
|
13
|
-
try {
|
|
14
|
-
// Search deep enough to discover nested/child git repos inside a monorepo
|
|
15
|
-
// workspace (e.g. projects/<name>/<sub>/.git) while pruning node_modules.
|
|
16
|
-
const result = await runShell(
|
|
17
|
-
`find . -maxdepth ${MAX_GIT_DEPTH} -name node_modules -prune -o -name .git -type d -print`,
|
|
18
|
-
{ cwd: rootDir, timeout: 12000, maxBuffer: 1024 * 1024 }
|
|
19
|
-
);
|
|
20
|
-
const discovered = result.stdout
|
|
21
|
-
.trim()
|
|
22
|
-
.split('\n')
|
|
23
|
-
.filter(Boolean)
|
|
24
|
-
.map(line => {
|
|
25
|
-
const cleaned = line.replace(/^\.\//, '').replace(/\/\.git$/, '');
|
|
26
|
-
return cleaned === '.git' || !cleaned ? '.' : cleaned;
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
const uniqueRepos = Array.from(new Set(discovered));
|
|
30
|
-
if (uniqueRepos.includes('.')) {
|
|
31
|
-
return ['.', ...uniqueRepos.filter(r => r !== '.')];
|
|
32
|
-
}
|
|
33
|
-
return uniqueRepos.length ? uniqueRepos : ['.'];
|
|
34
|
-
} catch {
|
|
35
|
-
return ['.'];
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// Nested-repo discovery is expensive (find over a deep workspace), so it runs
|
|
40
|
-
// in the background after the loader returns the root status. Results are
|
|
41
|
-
// cached per scoped root and the client polls `?reposOnly=1` until ready.
|
|
42
|
-
interface RepoDiscovery {
|
|
43
|
-
repos: string[];
|
|
44
|
-
done: boolean;
|
|
45
|
-
}
|
|
46
|
-
const repoDiscovery = new Map<string, RepoDiscovery>();
|
|
47
|
-
|
|
48
|
-
function startRepoScan(rootDir: string): void {
|
|
49
|
-
if (repoDiscovery.has(rootDir)) return;
|
|
50
|
-
const d: RepoDiscovery = { repos: ['.'], done: false };
|
|
51
|
-
repoDiscovery.set(rootDir, d);
|
|
52
|
-
void getRepos(rootDir)
|
|
53
|
-
.then(repos => { d.repos = repos; d.done = true; })
|
|
54
|
-
.catch(() => { d.repos = ['.']; d.done = true; });
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function discoveredRepos(rootDir: string): { repos: string[]; pending: boolean } {
|
|
58
|
-
const d = repoDiscovery.get(rootDir);
|
|
59
|
-
if (!d) return { repos: ['.'], pending: false };
|
|
60
|
-
return { repos: d.done ? d.repos : ['.'], pending: !d.done };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
24
|
export async function loader({ request }: LoaderFunctionArgs) {
|
|
64
25
|
const url = new URL(request.url);
|
|
65
26
|
const rootDir = await resolveRoot(url.searchParams.get('root'), process.cwd());
|
|
@@ -68,10 +29,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|
|
68
29
|
// whatever is already cached. `rescan=1` invalidates the cache so a fresh
|
|
69
30
|
// discovery pass starts (used by the repo-switcher refresh button).
|
|
70
31
|
const rescan = url.searchParams.get('rescan') === '1';
|
|
71
|
-
if (rescan)
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
startRepoScan(rootDir);
|
|
32
|
+
if (rescan) rescanRepos(rootDir);
|
|
33
|
+
else startRepoScan(rootDir);
|
|
75
34
|
const { repos, pending } = discoveredRepos(rootDir);
|
|
76
35
|
|
|
77
36
|
// Lightweight polling endpoint: just the discovered repos, no git status.
|
|
@@ -98,7 +57,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|
|
98
57
|
if (targetFile) {
|
|
99
58
|
const staged = url.searchParams.get('staged') === '1' || url.searchParams.get('staged') === 'true';
|
|
100
59
|
const statusParam = url.searchParams.get('status') || undefined;
|
|
101
|
-
const
|
|
60
|
+
const fullContext = url.searchParams.get('fullContext') === '1';
|
|
61
|
+
const diffData = await fetchWorkingFileDiff(targetDir, targetFile, staged, statusParam, fullContext);
|
|
102
62
|
return json({ success: true, ...diffData });
|
|
103
63
|
}
|
|
104
64
|
}
|
|
@@ -133,23 +93,54 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|
|
133
93
|
const statusOut = (await runShell('git status --porcelain=v1 -uall', { cwd: targetDir, maxBuffer: 1024 * 1024 })).stdout;
|
|
134
94
|
|
|
135
95
|
let branch = 'main';
|
|
136
|
-
|
|
137
|
-
|
|
96
|
+
// Seeded empty, not `['main']`: the seed used to be pushed as a real local
|
|
97
|
+
// branch, so a repository whose only branch is `main` listed it twice.
|
|
98
|
+
const branches: string[] = [];
|
|
99
|
+
const remoteBranches: string[] = [];
|
|
138
100
|
try {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
101
|
+
// One `for-each-ref` answers all three questions the three previous
|
|
102
|
+
// spawns asked separately (`rev-parse --abbrev-ref HEAD`, `branch`,
|
|
103
|
+
// `branch -r`): `%(HEAD)` marks the checked-out branch with `*`, and the
|
|
104
|
+
// two refspecs cover local and remote in one listing. Measured 8.15 ms
|
|
105
|
+
// against 19.63 ms for the three-call form, on a 5s poll.
|
|
106
|
+
//
|
|
107
|
+
// Classification reads the FULL refname: `%(refname:short)` maps
|
|
108
|
+
// `refs/remotes/origin/HEAD` to the bare `origin`, which has no slash and
|
|
109
|
+
// would otherwise be listed as a local branch.
|
|
110
|
+
const refsOut = (await runShell(
|
|
111
|
+
'git for-each-ref --format="%(refname)%09%(refname:short)%09%(HEAD)" refs/heads refs/remotes',
|
|
112
|
+
{ cwd: targetDir },
|
|
113
|
+
)).stdout;
|
|
114
|
+
let sawHead = false;
|
|
115
|
+
for (const line of refsOut.split('\n')) {
|
|
116
|
+
const [full, short, head] = line.split('\t');
|
|
117
|
+
if (!full || !short) continue;
|
|
118
|
+
// `refs/remotes/origin/HEAD` is a symbolic alias, not a branch.
|
|
119
|
+
if (full.endsWith('/HEAD')) continue;
|
|
120
|
+
if (full.startsWith('refs/remotes/')) {
|
|
121
|
+
remoteBranches.push(short);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
branches.push(short);
|
|
125
|
+
if (head === '*') {
|
|
126
|
+
branch = short;
|
|
127
|
+
sawHead = true;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Detached HEAD: no local ref is marked, and the branch is the sha
|
|
131
|
+
// `rev-parse --abbrev-ref HEAD` prints. Ask for it rather than reporting
|
|
132
|
+
// a branch the checkout is not on.
|
|
133
|
+
if (!sawHead) {
|
|
134
|
+
const headOut = await runShell('git rev-parse --abbrev-ref HEAD', { cwd: targetDir });
|
|
135
|
+
branch = headOut.stdout.trim() || 'main';
|
|
149
136
|
}
|
|
150
137
|
} catch {
|
|
151
138
|
// ignore branch resolution errors
|
|
152
139
|
}
|
|
140
|
+
// `main` remains the answer only when nothing could be read (a non-repo
|
|
141
|
+
// directory, git missing) — a real listing always supplies its own refs.
|
|
142
|
+
if (branches.length === 0) branches.push('main');
|
|
143
|
+
else if (!branches.includes(branch)) branches.unshift(branch);
|
|
153
144
|
|
|
154
145
|
const changes = statusOut
|
|
155
146
|
.split('\n')
|
|
@@ -22,7 +22,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|
|
22
22
|
const resolved = await resolveSessionFileOr404(sessionId);
|
|
23
23
|
if ('response' in resolved) return resolved.response;
|
|
24
24
|
const { filePath } = resolved;
|
|
25
|
-
const stats = readSessionStats(filePath);
|
|
25
|
+
const stats = await readSessionStats(filePath);
|
|
26
26
|
if (!stats) {
|
|
27
27
|
return json({ error: 'Session could not be parsed' }, { status: 500 });
|
|
28
28
|
}
|
|
@@ -4,7 +4,7 @@ import { methodNotAllowed } from '@/server/lib/route-adapter';
|
|
|
4
4
|
import { BREAKDOWN_DATA, MOCK_CHART_SERIES, MOCK_CUSTOM_RANGE_DATA, MOCK_RANGE_DATA, TIME_RANGES } from '@/client/data/mock/token-usage';
|
|
5
5
|
import { isMockMode } from '@/server/mock.server';
|
|
6
6
|
import { loadModelsDevCatalog } from '@/shared/lib/models/catalog';
|
|
7
|
-
import {
|
|
7
|
+
import { aggregateUsageWindows, PRESET_RANGES, type UsageWindow } from '@/server/lib/omp/session/usage/aggregate';
|
|
8
8
|
import { buildChartSeries, toBreakdownRows, toMetricSet } from '@/server/lib/omp/session/usage/shaping';
|
|
9
9
|
import type { BreakdownRow, BreakdownTab, CadenceType, ChartSeriesPoint, TimeRangeType, TokenUsageMetricSet } from '@/shared/types';
|
|
10
10
|
|
|
@@ -22,7 +22,6 @@ const REAL_ZERO_METRIC: TokenUsageMetricSet = {
|
|
|
22
22
|
usageRecords: 0,
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
-
const PRESET_RANGES: Exclude<TimeRangeType, 'custom'>[] = ['today', '7d', '30d', '90d', 'all'];
|
|
26
25
|
const CADENCES: CadenceType[] = ['daily', 'weekly', 'monthly'];
|
|
27
26
|
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
28
27
|
|
|
@@ -104,12 +103,15 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|
|
104
103
|
|
|
105
104
|
// Real Mode: aggregate actual token/cost usage from omp session files.
|
|
106
105
|
try {
|
|
106
|
+
// One scan, every window: the tree read is the expensive part (measured
|
|
107
|
+
// 795 ms over 455 transcripts), and folding six windows from one scan
|
|
108
|
+
// replaced six separate reads — 3.3 s cold on this machine.
|
|
109
|
+
const { ranges, active: activeAggregate } = await aggregateUsageWindows(window);
|
|
107
110
|
const realRangeData = {} as Record<Exclude<TimeRangeType, 'custom'>, TokenUsageMetricSet>;
|
|
108
111
|
for (const range of PRESET_RANGES) {
|
|
109
|
-
realRangeData[range] = toMetricSet(
|
|
112
|
+
realRangeData[range] = toMetricSet(ranges[range], cadence);
|
|
110
113
|
}
|
|
111
114
|
|
|
112
|
-
const activeAggregate = await aggregateUsage(window);
|
|
113
115
|
const realBreakdown: Record<BreakdownTab, BreakdownRow[]> = {
|
|
114
116
|
model: toBreakdownRows(activeAggregate, 'model'),
|
|
115
117
|
day: toBreakdownRows(activeAggregate, 'day', cadence),
|
|
@@ -14,18 +14,14 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|
|
14
14
|
const url = new URL(request.url);
|
|
15
15
|
const rootDir = await resolveRoot(url.searchParams.get('root'), process.cwd());
|
|
16
16
|
const targetDir = await scopeToRepo(rootDir, url.searchParams.get('repo'));
|
|
17
|
-
|
|
17
|
+
// The server IS the Bun being reported, so the version is already in this
|
|
18
|
+
// process. Spawning `bun --version` through a shell cost 10.4 ms per header
|
|
19
|
+
// load to read a constant `Bun.version` answers in 0.05 ms.
|
|
20
|
+
const bunVersion = Bun.version;
|
|
18
21
|
// `process.version` is the Node version Bun emulates, not a real Node binary.
|
|
19
22
|
const nodeVersion = process.version;
|
|
20
23
|
let gitBranch = 'main';
|
|
21
24
|
|
|
22
|
-
try {
|
|
23
|
-
const bunOut = await runShell('bun --version', { cwd: targetDir });
|
|
24
|
-
bunVersion = bunOut.stdout.trim();
|
|
25
|
-
} catch {
|
|
26
|
-
bunVersion = Bun.version;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
25
|
try {
|
|
30
26
|
const branchOut = await runShell('git rev-parse --abbrev-ref HEAD', { cwd: targetDir });
|
|
31
27
|
gitBranch = branchOut.stdout.trim() || 'main';
|
|
@@ -66,9 +66,24 @@ export const terminalWsRoutes = new Elysia({ prefix: '/api/terminal' }).ws('/:te
|
|
|
66
66
|
viewer: {
|
|
67
67
|
send(frame) {
|
|
68
68
|
try {
|
|
69
|
-
|
|
69
|
+
// Bun returns -1 when the frame was dropped rather than queued
|
|
70
|
+
// (the socket is over its high-water mark). Reporting it lets the
|
|
71
|
+
// session close this viewer instead of buffering a PTY's output
|
|
72
|
+
// into a socket nobody is reading.
|
|
73
|
+
return ws.send(frame) !== -1;
|
|
70
74
|
} catch {
|
|
71
75
|
// Socket closed mid-send; the close handler detaches.
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
drop() {
|
|
80
|
+
// The terminal session removed this viewer; close the socket so the
|
|
81
|
+
// client reconnects and replays the scrollback rather than sitting
|
|
82
|
+
// on a stream with a hole in it.
|
|
83
|
+
try {
|
|
84
|
+
ws.close();
|
|
85
|
+
} catch {
|
|
86
|
+
// Already gone.
|
|
72
87
|
}
|
|
73
88
|
},
|
|
74
89
|
},
|
|
@@ -114,7 +129,14 @@ export const terminalWsRoutes = new Elysia({ prefix: '/api/terminal' }).ws('/:te
|
|
|
114
129
|
// Ordering is safe without sequence numbers: attach captured the history
|
|
115
130
|
// and registered this viewer in one synchronous block, so nothing can be
|
|
116
131
|
// delivered between this frame and the replay that follows it.
|
|
117
|
-
|
|
132
|
+
//
|
|
133
|
+
// The replay goes through the viewer, so a socket that cannot take the
|
|
134
|
+
// scrollback is closed rather than left attached to a terminal whose
|
|
135
|
+
// first screenful was silently dropped.
|
|
136
|
+
if (replay.length > 0 && !attachment.viewer.send(replay)) {
|
|
137
|
+
detachTerminal(attachment.viewer, attachment.id);
|
|
138
|
+
attachment.viewer.drop();
|
|
139
|
+
}
|
|
118
140
|
} catch (error) {
|
|
119
141
|
const code = error instanceof TerminalRuntimeError ? error.code : 'unknown';
|
|
120
142
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -27,12 +27,14 @@ function makeDeps() {
|
|
|
27
27
|
const activity: string[] = [];
|
|
28
28
|
const commandOutputs: string[] = [];
|
|
29
29
|
let settledCount = 0;
|
|
30
|
+
let runEnds = 0;
|
|
30
31
|
let state: OmpAgentState = { isGenerating: false, connected: true, error: null };
|
|
31
32
|
const callbacks: OmpAgentCallbacks = {
|
|
32
33
|
onMessageEnd: (msg) => ended.push(msg),
|
|
33
34
|
onActivity: (verb) => activity.push(verb),
|
|
34
35
|
onPromptSettled: () => { settledCount += 1; },
|
|
35
36
|
onCommandOutput: (text) => commandOutputs.push(text),
|
|
37
|
+
onAgentEnd: () => { runEnds += 1; },
|
|
36
38
|
};
|
|
37
39
|
const deps: OmpAgentFoldDeps = {
|
|
38
40
|
sessionId: 's1',
|
|
@@ -47,7 +49,7 @@ function makeDeps() {
|
|
|
47
49
|
currentThinkingLevelRef: { current: undefined },
|
|
48
50
|
fileMutatingCallsRef: { current: new Set<string>() },
|
|
49
51
|
};
|
|
50
|
-
return { deps, ended, activity, commandOutputs, settled: () => settledCount, state: () => state };
|
|
52
|
+
return { deps, ended, activity, commandOutputs, settled: () => settledCount, runEnds: () => runEnds, state: () => state };
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
describe('toChatMessage error derivation', () => {
|
|
@@ -185,4 +187,34 @@ describe('foldAgentEvent agent_end', () => {
|
|
|
185
187
|
foldAgentEvent({ type: 'agent_end', messages: [{ ...ABORTED_TURN, stopReason: 'end_turn' }] }, deps);
|
|
186
188
|
expect(ended).toHaveLength(0);
|
|
187
189
|
});
|
|
190
|
+
|
|
191
|
+
test('a non-terminal agent_end keeps the run generating and does not fire onAgentEnd', () => {
|
|
192
|
+
// omp's shape when the turn yields with work still alive — a detached
|
|
193
|
+
// subagent, a compaction, a background job. Measured on omp 18.3.2: with an
|
|
194
|
+
// async subagent, this frame lands ~14s BEFORE the subagent finishes, and
|
|
195
|
+
// treating it as the run end blanked the generating indicator for the whole
|
|
196
|
+
// stretch the roster was still showing a live child.
|
|
197
|
+
const { deps, state, runEnds } = makeDeps();
|
|
198
|
+
foldAgentEvent({ type: 'agent_start' }, deps);
|
|
199
|
+
expect(state().isGenerating).toBe(true);
|
|
200
|
+
|
|
201
|
+
foldAgentEvent({ type: 'agent_end', isTerminal: false, messages: [] }, deps);
|
|
202
|
+
expect(state().isGenerating).toBe(true);
|
|
203
|
+
expect(runEnds()).toBe(0);
|
|
204
|
+
|
|
205
|
+
// The continuation ends the run for real; only now may the UI settle.
|
|
206
|
+
foldAgentEvent({ type: 'agent_start' }, deps);
|
|
207
|
+
foldAgentEvent({ type: 'agent_end', isTerminal: true, messages: [] }, deps);
|
|
208
|
+
expect(state().isGenerating).toBe(false);
|
|
209
|
+
expect(runEnds()).toBe(1);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('an agent_end with no isTerminal field is treated as terminal', () => {
|
|
213
|
+
// The field is optional: a frame that omits it is a normal run end.
|
|
214
|
+
const { deps, state, runEnds } = makeDeps();
|
|
215
|
+
foldAgentEvent({ type: 'agent_start' }, deps);
|
|
216
|
+
foldAgentEvent({ type: 'agent_end', messages: [] }, deps);
|
|
217
|
+
expect(state().isGenerating).toBe(false);
|
|
218
|
+
expect(runEnds()).toBe(1);
|
|
219
|
+
});
|
|
188
220
|
});
|
|
@@ -225,6 +225,16 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
|
|
|
225
225
|
}
|
|
226
226
|
|
|
227
227
|
case 'agent_end': {
|
|
228
|
+
// A NON-TERMINAL `agent_end` is not the end of the run: omp emits it when
|
|
229
|
+
// the turn yields but work is still alive — a detached subagent, a
|
|
230
|
+
// compaction, a background job, or a stop-time reminder — and a later
|
|
231
|
+
// `agent_start` opens the continuation. Measured on omp 18.3.2: with an
|
|
232
|
+
// async subagent, `agent_end isTerminal=false` arrives ~13s BEFORE the
|
|
233
|
+
// subagent's terminal frame, and clearing the generating state there
|
|
234
|
+
// blanked the indicator for the whole stretch the roster was still
|
|
235
|
+
// showing a live child. The server's own fold gates on the same field
|
|
236
|
+
// (frame-fold.ts), so the two ends now agree.
|
|
237
|
+
if (data.isTerminal === false) break;
|
|
228
238
|
deps.setState((prev) => ({ ...prev, isGenerating: false }));
|
|
229
239
|
deps.activityRef.current = '';
|
|
230
240
|
const terminal = materializeTerminalMessages(data, deps, callbacks ?? undefined);
|