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.
Files changed (124) hide show
  1. package/README.md +6 -3
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +279 -280
  4. package/dist/client/static/js/index-01dv383b.css +1 -0
  5. package/dist/client/static/js/{index-8t70jhyj.js → index-1q6165r3.js} +641 -641
  6. package/package.json +1 -1
  7. package/src/cli/lib/args.js +4 -3
  8. package/src/cli/lib/commands/restart.js +7 -6
  9. package/src/cli/lib/commands/stop.js +38 -31
  10. package/src/cli/lib/commands/update.js +3 -2
  11. package/src/cli/lib/output.js +15 -8
  12. package/src/cli/lib/stop-scope.js +23 -0
  13. package/src/cli/lib/stop-scope.test.js +41 -0
  14. package/src/client/components/common/Modal.tsx +14 -4
  15. package/src/client/components/{layout/session-sidebar/SubagentList.tsx → common/subagent-list/index.tsx} +8 -10
  16. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +6 -30
  17. package/src/client/components/layout/session-sidebar/Toolbar.tsx +11 -5
  18. package/src/client/components/layout/session-sidebar/index.tsx +4 -2
  19. package/src/client/components/mobile/LayoutWrapper.tsx +6 -0
  20. package/src/client/components/mobile/SessionSidebar.tsx +4 -0
  21. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +66 -29
  22. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +50 -12
  23. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
  24. package/src/client/components/settings/Modal.tsx +35 -6
  25. package/src/client/components/settings/SettingsSidebarList.tsx +1 -1
  26. package/src/client/components/settings/categories/AgentSettings.tsx +45 -28
  27. package/src/client/components/settings/categories/CommandSettings.tsx +45 -28
  28. package/src/client/components/settings/categories/McpSettings.tsx +48 -31
  29. package/src/client/components/settings/categories/ProjectSettings.tsx +34 -19
  30. package/src/client/components/settings/categories/ProviderSettings.tsx +77 -53
  31. package/src/client/components/settings/categories/SkillSettings.tsx +34 -17
  32. package/src/client/components/settings/categories/UsageSettings.tsx +53 -39
  33. package/src/client/components/settings/categories/behavior-settings/Editor.tsx +7 -7
  34. package/src/client/components/settings/categories/mcp-settings/ImportModal.tsx +1 -1
  35. package/src/client/components/settings/categories/mcp-settings/SidebarList.tsx +1 -1
  36. package/src/client/components/settings/categories/project-settings/SidebarList.tsx +1 -1
  37. package/src/client/components/settings/categories/provider-settings/AuthSection.tsx +8 -3
  38. package/src/client/components/settings/categories/provider-settings/DeleteProviderModal.tsx +1 -1
  39. package/src/client/components/settings/categories/provider-settings/ModelsList.tsx +9 -4
  40. package/src/client/components/settings/categories/provider-settings/SidebarList.tsx +1 -1
  41. package/src/client/components/settings/categories/skill-catalog/AddSourceModal.tsx +1 -1
  42. package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +1 -1
  43. package/src/client/components/settings/categories/usage-settings/SidebarList.tsx +1 -1
  44. package/src/client/components/settings/master-detail/index.tsx +68 -0
  45. package/src/client/components/workspace/SearchPanel.tsx +2 -1
  46. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +7 -1
  47. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +2 -1
  48. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +2 -1
  49. package/src/client/components/workspace/chat-timeline/index.tsx +1 -0
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +2 -1
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +2 -1
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +2 -1
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +2 -1
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +2 -1
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +2 -1
  56. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +2 -1
  57. package/src/client/components/workspace/diff-panel/SplitView.tsx +31 -5
  58. package/src/client/components/workspace/diff-panel/Toolbar.tsx +77 -6
  59. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +18 -5
  60. package/src/client/components/workspace/diff-panel/index.tsx +36 -7
  61. package/src/client/components/workspace/editor/Toolbar.tsx +11 -4
  62. package/src/client/components/workspace/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
  63. package/src/client/hooks/chat/omp/session-list.tsx +31 -1
  64. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +7 -1
  65. package/src/client/hooks/chat/subagent/index.ts +37 -11
  66. package/src/client/hooks/editor/use-file-editor.ts +1 -1
  67. package/src/client/hooks/settings/master-detail.ts +33 -0
  68. package/src/client/hooks/workspace/expanded-sessions.ts +62 -0
  69. package/src/server/lib/bundler/css.test.ts +87 -0
  70. package/src/server/lib/bundler/css.ts +17 -2
  71. package/src/server/lib/bundler/nested-at-rules.test.ts +100 -0
  72. package/src/server/lib/bundler/nested-at-rules.ts +146 -0
  73. package/src/server/lib/db/client.ts +5 -1
  74. package/src/server/lib/fs/git-diff.ts +34 -10
  75. package/src/server/lib/fs/git-repos.ts +81 -0
  76. package/src/server/lib/lifecycle/flock.ts +199 -0
  77. package/src/server/lib/lifecycle/identity.ts +5 -1
  78. package/src/server/lib/lifecycle/launch-mode.test.ts +20 -1
  79. package/src/server/lib/lifecycle/launch-mode.ts +26 -0
  80. package/src/server/lib/lifecycle/port-guard.ts +18 -3
  81. package/src/server/lib/lifecycle/proc/darwin.ts +52 -17
  82. package/src/server/lib/lifecycle/proc/linux.ts +29 -7
  83. package/src/server/lib/lifecycle/proc/ps.ts +7 -0
  84. package/src/server/lib/lifecycle/proc/types.ts +17 -0
  85. package/src/server/lib/lifecycle/proc/win32.ts +5 -0
  86. package/src/server/lib/lifecycle/restart.ts +9 -14
  87. package/src/server/lib/observer-ws.ts +150 -0
  88. package/src/server/lib/omp/rpc/constants.ts +9 -0
  89. package/src/server/lib/omp/rpc/subagent-liveness.ts +2 -12
  90. package/src/server/lib/omp/session/file-scan.ts +7 -1
  91. package/src/server/lib/omp/session/files.ts +95 -96
  92. package/src/server/lib/omp/session/locator.ts +18 -8
  93. package/src/server/lib/omp/session/messages.ts +7 -7
  94. package/src/server/lib/omp/session/scan.ts +108 -0
  95. package/src/server/lib/omp/session/stats.ts +50 -50
  96. package/src/server/lib/omp/session/telemetry/scan.ts +89 -7
  97. package/src/server/lib/omp/session/telemetry-raw.ts +24 -8
  98. package/src/server/lib/omp/session/usage/aggregate.ts +170 -55
  99. package/src/server/lib/terminal/runtime.server.ts +3 -0
  100. package/src/server/lib/terminal/session.server.ts +30 -3
  101. package/src/server/routes/agent/command.test.ts +69 -0
  102. package/src/server/routes/agent/command.ts +14 -0
  103. package/src/server/routes/agent/ws.test.ts +159 -0
  104. package/src/server/routes/agent/ws.ts +22 -97
  105. package/src/server/routes/btw/ws.ts +22 -95
  106. package/src/server/routes/fs/git.ts +61 -70
  107. package/src/server/routes/omp/session-stats.ts +1 -1
  108. package/src/server/routes/telemetry/tokens.ts +6 -4
  109. package/src/server/routes/terminal/run.ts +4 -8
  110. package/src/server/routes/terminal/ws.ts +24 -2
  111. package/src/shared/lib/chat/omp/agent-events.test.ts +33 -1
  112. package/src/shared/lib/chat/omp/agent-events.ts +10 -0
  113. package/src/shared/lib/code/language.ts +146 -0
  114. package/src/shared/lib/code/syntax-highlight.test.ts +2 -1
  115. package/src/shared/lib/code/syntax-highlight.ts +0 -122
  116. package/src/shared/lib/fs/diff-parser.test.ts +5 -5
  117. package/src/shared/lib/fs/diff-parser.ts +67 -42
  118. package/src/shared/lib/omp/subagent/parse.test.ts +88 -0
  119. package/src/shared/lib/omp/subagent/parse.ts +17 -0
  120. package/src/shared/lib/omp/subagent/transcript-client.test.ts +100 -0
  121. package/src/shared/lib/omp/subagent/transcript-client.ts +17 -1
  122. package/src/shared/types/omp/subagent.ts +6 -0
  123. package/dist/client/static/js/index-n4wkqj7t.css +0 -1
  124. 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 now lives in
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 { createEventCoalescer, type EventCoalescer } from '@/server/lib/sse';
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
- interface ConnectionState {
52
- closed: boolean;
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 sessionId = ws.data.params.sessionId;
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: ConnectionState = {
79
- closed: false,
80
- unsubscribe: null,
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
- const teardown = () => {
88
- if (state.closed) return;
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 = connections.get(ws);
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 = connections.get(ws);
155
- if (!state || state.closed) return;
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 { createEventCoalescer, type EventCoalescer } from '@/server/lib/sse';
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
- /** Coalesce frames once this many bytes sit unsent. */
24
- const HIGH_WATER_BYTES = 256 * 1024;
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 sessionId = ws.data.params.sessionId;
44
- const state: ConnectionState = { closed: false, unsubscribe: null, heartbeat: null, awaitingPong: false, coalescer: null };
45
- connections.set(ws, state);
46
- // Unchecked: Elysia exposes the server socket untyped. Both members are
47
- // optional, so an absent one degrades (no byte count / no keepalive)
48
- // instead of failing.
49
- const raw = ws.raw as { bufferedAmount?: number; ping?: () => void };
50
-
51
- const teardown = () => {
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) => push({ type: 'btw_state', state: snapshot }))
44
+ .then((snapshot) => send({ type: 'btw_state', state: snapshot }))
107
45
  .catch((error: unknown) => {
108
- push({ type: 'btw_error', topicId: null, message: error instanceof Error ? error.message : String(error) });
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 = connections.get(ws);
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 = connections.get(ws);
123
- if (!state || state.closed) return;
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
- repoDiscovery.delete(rootDir);
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 diffData = await fetchWorkingFileDiff(targetDir, targetFile, staged, statusParam);
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
- let branches: string[] = ['main'];
137
- let remoteBranches: string[] = [];
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
- const branchOut = await runShell('git rev-parse --abbrev-ref HEAD', { cwd: targetDir });
140
- branch = branchOut.stdout.trim() || 'main';
141
-
142
- const localOut = await runShell('git branch --format="%(refname:short)"', { cwd: targetDir });
143
- branches = localOut.stdout.trim().split('\n').filter(Boolean);
144
-
145
- const remoteOut = await runShell('git branch -r --format="%(refname:short)"', { cwd: targetDir });
146
- remoteBranches = remoteOut.stdout.trim().split('\n').filter(Boolean).filter(b => b.includes('/') && !b.endsWith('/HEAD'));
147
- if (!branches.includes(branch)) {
148
- branches.unshift(branch);
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 { aggregateUsage, type UsageWindow } from '@/server/lib/omp/session/usage/aggregate';
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(await aggregateUsage({ kind: 'preset', range }), cadence);
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
- let bunVersion = '';
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
- ws.send(frame);
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
- if (replay.length > 0) ws.send(replay);
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);