ompchamber 3.1.1 → 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/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-qhzsybkn.js → index-1q6165r3.js} +641 -641
- package/package.json +1 -1
- 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-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/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/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/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-5yfre1kw.css +0 -1
|
@@ -67,6 +67,13 @@ export const psProbe: ProcessProbe = {
|
|
|
67
67
|
const row = processTable().find((entry) => entry.pid === pid);
|
|
68
68
|
return row && row.command.length > 0 ? row.command : null;
|
|
69
69
|
},
|
|
70
|
+
liveness(pid) {
|
|
71
|
+
// The table already carries the state column, so liveness and zombie-ness
|
|
72
|
+
// are one lookup; the signal check stays for the no-`ps` case.
|
|
73
|
+
const row = processTable().find((entry) => entry.pid === pid);
|
|
74
|
+
if (row) return row.state.startsWith('Z') ? 'zombie' : 'alive';
|
|
75
|
+
return signalAlive(pid) ? 'unknown' : 'dead';
|
|
76
|
+
},
|
|
70
77
|
isAlive(pid) {
|
|
71
78
|
if (!signalAlive(pid)) return false;
|
|
72
79
|
const row = processTable().find((entry) => entry.pid === pid);
|
|
@@ -11,9 +11,26 @@
|
|
|
11
11
|
* PID is never worth failing a request over, and a platform that cannot answer
|
|
12
12
|
* says so (`null` / `false`) so the caller can fall back.
|
|
13
13
|
*/
|
|
14
|
+
export type ProcessLiveness =
|
|
15
|
+
/** The PID exists and is running. */
|
|
16
|
+
| 'alive'
|
|
17
|
+
/** Exited, waiting for its parent to reap it: holds no port, never runs again. */
|
|
18
|
+
| 'zombie'
|
|
19
|
+
/** No such process. */
|
|
20
|
+
| 'dead'
|
|
21
|
+
/** The platform could not answer — callers must NOT read this as "dead". */
|
|
22
|
+
| 'unknown';
|
|
23
|
+
|
|
14
24
|
export interface ProcessProbe {
|
|
15
25
|
/** Command line of a live PID, or null when unreadable. */
|
|
16
26
|
commandLine(pid: number): string | null;
|
|
27
|
+
/**
|
|
28
|
+
* Liveness in ONE lookup. This is the question `identity.ts` actually asks,
|
|
29
|
+
* and asking it as `isAlive(pid) && !isZombie(pid)` made the macOS probe
|
|
30
|
+
* issue the identical `sysctl(KERN_PROC_PID)` twice for the same PID
|
|
31
|
+
* (measured 21.3 µs against 5.3 µs for the single read).
|
|
32
|
+
*/
|
|
33
|
+
liveness(pid: number): ProcessLiveness;
|
|
17
34
|
/** True when the PID exists. */
|
|
18
35
|
isAlive(pid: number): boolean;
|
|
19
36
|
/** True when the PID is defunct — exited but not yet reaped by its parent.
|
|
@@ -40,6 +40,11 @@ export const win32Probe: ProcessProbe = {
|
|
|
40
40
|
commandLine() {
|
|
41
41
|
return null;
|
|
42
42
|
},
|
|
43
|
+
liveness(pid) {
|
|
44
|
+
// No zombie state is observable on Windows (a process object lives until
|
|
45
|
+
// its last handle closes), so signal 0 is the whole answer.
|
|
46
|
+
return signalAlive(pid) ? 'alive' : 'dead';
|
|
47
|
+
},
|
|
43
48
|
isAlive: signalAlive,
|
|
44
49
|
isZombie() {
|
|
45
50
|
return false;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Shared lifecycle for the chamber's observer WebSocket streams (agent events,
|
|
8
|
+
* BTW frames). Both routes hand-rolled the same connection state — an
|
|
9
|
+
* unsubscribe slot, a ping/pong keepalive, a latest-wins coalescer, and a
|
|
10
|
+
* teardown that releases all three — so the policy lives here once, exactly as
|
|
11
|
+
* `createSseStream` and `createEventCoalescer` own the SSE side.
|
|
12
|
+
*
|
|
13
|
+
* **Per-connection state must live on `ws.data`, never in a `WeakMap` keyed by
|
|
14
|
+
* the `ws` object.** Elysia's Bun adapter constructs a NEW `ElysiaWS` wrapper
|
|
15
|
+
* for every callback and hands `pong` the RAW `ServerWebSocket` rather than a
|
|
16
|
+
* wrapper at all (verified on 1.4.30: `open` receives `ElysiaWS`, `pong`
|
|
17
|
+
* receives the raw socket, and a `WeakMap` lookup in either misses). A missed
|
|
18
|
+
* `pong` lookup never clears `awaitingPong`, so the second heartbeat tears down
|
|
19
|
+
* a socket the client is still reading — every observer stream died at 60s
|
|
20
|
+
* (measured: a fresh chamber instance closed a healthy socket at 60.005ms with
|
|
21
|
+
* `STREAM_HEARTBEAT_MS` = 30s). A missed `close` lookup leaks the interval, the
|
|
22
|
+
* coalescer and the session subscription for the process's lifetime.
|
|
23
|
+
*
|
|
24
|
+
* `ws.data` is one object shared by every callback, so it is the only stable
|
|
25
|
+
* key. The routes keep their own store object in it and pass it here.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { createEventCoalescer, type EventCoalescer } from '@/server/lib/sse';
|
|
29
|
+
import { STREAM_HEARTBEAT_MS } from '@/shared/lib/workspace/refresh-cadence';
|
|
30
|
+
|
|
31
|
+
/** Coalesce replaceable frames once this many bytes sit unsent. */
|
|
32
|
+
const HIGH_WATER_BYTES = 256 * 1024;
|
|
33
|
+
|
|
34
|
+
/** Longest a coalesced frame may wait before it is flushed anyway, so a burst
|
|
35
|
+
* that ends while the socket is still busy never strands the newest frame. */
|
|
36
|
+
const FLUSH_DELAY_MS = 50;
|
|
37
|
+
|
|
38
|
+
/** The Bun server socket, narrowed to the two members this module reads. */
|
|
39
|
+
interface ObserverRawSocket {
|
|
40
|
+
bufferedAmount?: number;
|
|
41
|
+
ping?: () => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The Elysia `ws` context, narrowed to the members this module uses. */
|
|
45
|
+
export interface ObserverSocket {
|
|
46
|
+
raw: unknown;
|
|
47
|
+
send: (data: string) => unknown;
|
|
48
|
+
close: () => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** State a route keeps on `ws.data`; `closed` is also read by its own guards. */
|
|
52
|
+
export interface ObserverConnectionState {
|
|
53
|
+
closed: boolean;
|
|
54
|
+
unsubscribe: (() => void) | null;
|
|
55
|
+
heartbeat: ReturnType<typeof setInterval> | null;
|
|
56
|
+
awaitingPong: boolean;
|
|
57
|
+
coalescer: EventCoalescer | null;
|
|
58
|
+
/** Idempotent release, installed by `attachObserverStream`. The route's
|
|
59
|
+
* `close` handler calls it; before attach it is a no-op. */
|
|
60
|
+
teardown: () => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A fresh state object for `ws.data`. */
|
|
64
|
+
export function createObserverState(): ObserverConnectionState {
|
|
65
|
+
return {
|
|
66
|
+
closed: false,
|
|
67
|
+
unsubscribe: null,
|
|
68
|
+
heartbeat: null,
|
|
69
|
+
awaitingPong: false,
|
|
70
|
+
coalescer: null,
|
|
71
|
+
teardown: () => {},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** What an attached connection exposes back to its route. */
|
|
76
|
+
export interface ObserverStream {
|
|
77
|
+
/** Emit one frame through the coalescer (latest-wins while backpressured). */
|
|
78
|
+
push: (event: string, data: unknown) => void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Attach the coalescer + keepalive to one accepted connection. Teardown is
|
|
83
|
+
* installed on `state` (the route's `close` handler calls it) and is idempotent
|
|
84
|
+
* from any path: a heartbeat, a send failure, or the route's own exit.
|
|
85
|
+
*
|
|
86
|
+
* The first frame is the route's own `connected` greeting, pushed by the caller.
|
|
87
|
+
*/
|
|
88
|
+
export function attachObserverStream(state: ObserverConnectionState, ws: ObserverSocket): ObserverStream {
|
|
89
|
+
// Elysia exposes the server socket untyped. Both members are optional, so an
|
|
90
|
+
// absent one degrades (no byte count / no keepalive) instead of failing.
|
|
91
|
+
const raw = (ws.raw ?? {}) as ObserverRawSocket;
|
|
92
|
+
|
|
93
|
+
const teardown = (): void => {
|
|
94
|
+
if (state.closed) return;
|
|
95
|
+
state.closed = true;
|
|
96
|
+
state.coalescer?.dispose();
|
|
97
|
+
state.coalescer = null;
|
|
98
|
+
if (state.heartbeat !== null) {
|
|
99
|
+
clearInterval(state.heartbeat);
|
|
100
|
+
state.heartbeat = null;
|
|
101
|
+
}
|
|
102
|
+
if (state.unsubscribe) {
|
|
103
|
+
try {
|
|
104
|
+
state.unsubscribe();
|
|
105
|
+
} catch {
|
|
106
|
+
// Subscriber cleanup is best-effort.
|
|
107
|
+
}
|
|
108
|
+
state.unsubscribe = null;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
ws.close();
|
|
112
|
+
} catch {
|
|
113
|
+
// Already gone.
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
state.coalescer = createEventCoalescer({
|
|
118
|
+
send: (_event, data) => {
|
|
119
|
+
try {
|
|
120
|
+
ws.send(JSON.stringify(data));
|
|
121
|
+
} catch {
|
|
122
|
+
teardown();
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
isBackpressured: () => (raw.bufferedAmount ?? 0) > HIGH_WATER_BYTES,
|
|
126
|
+
flushDelayMs: FLUSH_DELAY_MS,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Protocol-level keepalive: a client that misses a full ping cycle is gone,
|
|
130
|
+
// and a live one answers without any application-level traffic. The pong
|
|
131
|
+
// arrives on the route's `pong` handler, which clears `awaitingPong` through
|
|
132
|
+
// the SAME state object.
|
|
133
|
+
state.heartbeat = setInterval(() => {
|
|
134
|
+
if (state.closed) return;
|
|
135
|
+
if (state.awaitingPong) {
|
|
136
|
+
teardown();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
state.awaitingPong = true;
|
|
140
|
+
if (!state.coalescer?.flush()) return;
|
|
141
|
+
try {
|
|
142
|
+
raw.ping?.();
|
|
143
|
+
} catch {
|
|
144
|
+
teardown();
|
|
145
|
+
}
|
|
146
|
+
}, STREAM_HEARTBEAT_MS);
|
|
147
|
+
|
|
148
|
+
state.teardown = teardown;
|
|
149
|
+
return { push: (event, data) => state.coalescer?.push(event, data) };
|
|
150
|
+
}
|
|
@@ -125,6 +125,15 @@ export const PASSTHROUGH_COMMANDS = new Set([
|
|
|
125
125
|
'login',
|
|
126
126
|
]);
|
|
127
127
|
|
|
128
|
+
// Commands that only READ live process state, with an RPC-free on-disk
|
|
129
|
+
// equivalent. A live session answers them on the fast path; for one the chamber
|
|
130
|
+
// does not manage, the spawn path must refuse them (409) rather than boot an
|
|
131
|
+
// omp child to answer a read. The sidebar renders a roster row for every
|
|
132
|
+
// session in the list, so an unconditional probe started a process per finished
|
|
133
|
+
// session merely looked at (measured: the omp process count grew on a session
|
|
134
|
+
// whose liveness probe had just reported `running: false`).
|
|
135
|
+
export const OBSERVER_ONLY_COMMANDS = new Set(['get_subagents', 'get_subagent_messages']);
|
|
136
|
+
|
|
128
137
|
// Commands that can carry user-attached images to the model. All of them must
|
|
129
138
|
// pass the same server-side per-image/count/aggregate validation before the
|
|
130
139
|
// payload reaches omp — a client is free to POST any of them directly.
|
|
@@ -21,22 +21,12 @@
|
|
|
21
21
|
* goes stale).
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { parseSubagentLifecycle,
|
|
25
|
-
import { isRecord } from '@/shared/lib/util/guards';
|
|
26
|
-
import type { SubagentProgress } from '@/shared/types/omp/subagent';
|
|
24
|
+
import { parseSubagentLifecycle, readSubagentProgressFrame } from '@/shared/lib/omp/subagent/parse';
|
|
27
25
|
|
|
28
26
|
/** Statuses that end a subagent. Mirrors omp's own registry, which deletes an
|
|
29
27
|
* entry the moment a non-`started` lifecycle arrives. */
|
|
30
28
|
const TERMINAL_STATUS: Record<string, true> = { completed: true, failed: true, aborted: true };
|
|
31
29
|
|
|
32
|
-
/** Progress frames arrive AgentProgress-shaped; some wrappers nest the snapshot. */
|
|
33
|
-
function readProgress(payload: unknown): SubagentProgress | undefined {
|
|
34
|
-
const direct = parseSubagentProgress(payload);
|
|
35
|
-
if (!isRecord(payload) || !isRecord(payload.progress)) return direct;
|
|
36
|
-
const nested = parseSubagentProgress(payload.progress);
|
|
37
|
-
return nested ? { ...nested, id: nested.id ?? direct?.id, index: nested.index ?? direct?.index } : direct;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
30
|
export class SubagentLiveness {
|
|
41
31
|
private readonly lastSeen = new Map<string, number>();
|
|
42
32
|
/** index → key, so an id-less progress frame lands on the entry its lifecycle
|
|
@@ -54,7 +44,7 @@ export class SubagentLiveness {
|
|
|
54
44
|
}
|
|
55
45
|
|
|
56
46
|
if (frame.type === 'subagent_progress') {
|
|
57
|
-
const progress =
|
|
47
|
+
const progress = readSubagentProgressFrame(frame.payload);
|
|
58
48
|
if (!progress) return;
|
|
59
49
|
const index = progress.index !== undefined && progress.index >= 0 ? progress.index : undefined;
|
|
60
50
|
const key = progress.id ?? (index !== undefined ? this.keyByIndex.get(index) ?? `idx:${index}` : undefined);
|
|
@@ -37,6 +37,12 @@ export interface SessionFilePrefix {
|
|
|
37
37
|
text: string;
|
|
38
38
|
size: number;
|
|
39
39
|
mtime: Date;
|
|
40
|
+
/**
|
|
41
|
+
* The same mtime as a raw `stat.mtimeMs`, for callers that key a cache on it.
|
|
42
|
+
* `Date.getTime()` truncates the fractional millisecond APFS reports, so a
|
|
43
|
+
* Date comparison never matches a fresh stat.
|
|
44
|
+
*/
|
|
45
|
+
mtimeMs: number;
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
export async function readTextPrefix(filePath: string, prefixBytes: number): Promise<SessionFilePrefix> {
|
|
@@ -44,7 +50,7 @@ export async function readTextPrefix(filePath: string, prefixBytes: number): Pro
|
|
|
44
50
|
const stat = await file.stat();
|
|
45
51
|
const prefixLength = Math.min(prefixBytes, stat.size);
|
|
46
52
|
const text = prefixLength > 0 ? await file.slice(0, prefixLength).text() : '';
|
|
47
|
-
return { text, size: stat.size, mtime: new Date(stat.mtimeMs) };
|
|
53
|
+
return { text, size: stat.size, mtime: new Date(stat.mtimeMs), mtimeMs: stat.mtimeMs };
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
/**
|
|
@@ -4,106 +4,32 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
7
|
+
* The session-file CACHE layer: which files exist, what each one's summary is,
|
|
8
|
+
* and how to resolve an id to a path without re-reading the tree.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
* oh-my-pi packages/coding-agent/src/session/session-listing.ts): it lists the
|
|
11
|
-
* per-project session directories under ~/.omp/agent/sessions and derives a
|
|
12
|
-
* compact summary per session from a small 4 KiB prefix window of each file —
|
|
13
|
-
* enough for a sidebar list without ever loading a full session into memory.
|
|
10
|
+
* Three caches, all keyed so they invalidate for free:
|
|
14
11
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
12
|
+
* - the file list, on the sessions root's per-project directory mtimes (a new
|
|
13
|
+
* session bumps its project dir, so add/remove invalidates it);
|
|
14
|
+
* - the per-file summary, on that file's `(size, mtimeMs)`;
|
|
15
|
+
* - the `id → path` index, rebuilt from the file list.
|
|
16
|
+
*
|
|
17
|
+
* The mtime key is the RAW `stat.mtimeMs`, never a `Date`: `getTime()`
|
|
18
|
+
* truncates the fractional millisecond APFS reports, which made the summary
|
|
19
|
+
* cache miss on every call (measured: 451 of 452 files).
|
|
20
|
+
*
|
|
21
|
+
* The read itself lives in `./scan`; `clearSessionFileCaches()` is the one
|
|
22
|
+
* invalidation point every mutation path already calls.
|
|
21
23
|
*/
|
|
22
24
|
|
|
23
25
|
import fs from 'fs';
|
|
24
26
|
import * as path from 'path';
|
|
25
27
|
import { getSessionsDir } from '@/server/lib/omp/core/paths';
|
|
26
|
-
import { readLastEntryTimestamp, readTextPrefix, SESSION_LIST_PREFIX_BYTES } from '@/server/lib/omp/session/file-scan';
|
|
27
|
-
import { countMessageMarkers, extractFirstDisplayMessageFromPrefix, extractTextFromContent, parseJsonlLenient, parseSessionListHeader } from '@/shared/lib/omp/session/jsonl';
|
|
28
28
|
import { isRecord } from '@/shared/lib/util/guards';
|
|
29
|
+
import { SESSION_TITLE_SLOT_BYTES, scanSessionInfo, type OmpSessionInfo } from '@/server/lib/omp/session/scan';
|
|
29
30
|
|
|
30
|
-
export
|
|
31
|
-
|
|
32
|
-
/** Compact summary of one session file — the sidebar list row. */
|
|
33
|
-
export interface OmpSessionInfo {
|
|
34
|
-
/** Absolute path of the .jsonl file. */
|
|
35
|
-
path: string;
|
|
36
|
-
id: string;
|
|
37
|
-
/** Working directory the session ran in (from the header). */
|
|
38
|
-
cwd: string;
|
|
39
|
-
/** User-facing title (title slot wins; falls back to header title). */
|
|
40
|
-
title?: string;
|
|
41
|
-
parentSessionPath?: string;
|
|
42
|
-
created: Date;
|
|
43
|
-
/** Last activity: the newest JSONL entry's own `timestamp`, falling back to
|
|
44
|
-
* the file mtime for a file the tail window cannot date. */
|
|
45
|
-
modified: Date;
|
|
46
|
-
/** Filesystem mtime. Scan-cache version only — never user-visible, and never
|
|
47
|
-
* an ordering key (see readLastEntryTimestamp for why). */
|
|
48
|
-
fileMtime: Date;
|
|
49
|
-
messageCount: number;
|
|
50
|
-
size: number;
|
|
51
|
-
firstMessage: string;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Scan a single session file into an OmpSessionInfo from a 4 KiB prefix window
|
|
56
|
-
* (header, title, first message) plus a bounded tail window (last activity).
|
|
57
|
-
* Faithful port of omp's scanSessionFile except for `modified`, which is the
|
|
58
|
-
* last entry's timestamp rather than the file mtime. Missing/unreadable/
|
|
59
|
-
* header-less files yield undefined instead of throwing.
|
|
60
|
-
*/
|
|
61
|
-
export async function scanSessionInfo(filePath: string): Promise<OmpSessionInfo | undefined> {
|
|
62
|
-
try {
|
|
63
|
-
const { text: prefix, size, mtime } = await readTextPrefix(filePath, SESSION_LIST_PREFIX_BYTES);
|
|
64
|
-
const lastEntryAt = await readLastEntryTimestamp(filePath, size, prefix);
|
|
65
|
-
const entries = parseJsonlLenient<Record<string, unknown>>(prefix);
|
|
66
|
-
const header = parseSessionListHeader(prefix, entries);
|
|
67
|
-
if (!header) return undefined;
|
|
68
|
-
|
|
69
|
-
let parsedMessageCount = 0;
|
|
70
|
-
let firstMessage = '';
|
|
71
|
-
let shortSummary: string | undefined;
|
|
72
|
-
for (let i = 1; i < entries.length; i++) {
|
|
73
|
-
const entry = entries[i] as {
|
|
74
|
-
type?: string;
|
|
75
|
-
message?: { role?: string; content?: unknown };
|
|
76
|
-
shortSummary?: string;
|
|
77
|
-
};
|
|
78
|
-
if (entry.type === 'compaction' && typeof entry.shortSummary === 'string') {
|
|
79
|
-
shortSummary = entry.shortSummary;
|
|
80
|
-
}
|
|
81
|
-
if (entry.type === 'message' && entry.message) {
|
|
82
|
-
parsedMessageCount++;
|
|
83
|
-
if (entry.message.role === 'user' && !firstMessage) {
|
|
84
|
-
firstMessage = extractTextFromContent(entry.message.content);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
firstMessage ||= extractFirstDisplayMessageFromPrefix(prefix) ?? '';
|
|
90
|
-
return {
|
|
91
|
-
path: filePath,
|
|
92
|
-
id: header.id,
|
|
93
|
-
cwd: header.cwd ?? '',
|
|
94
|
-
title: header.title ?? shortSummary,
|
|
95
|
-
parentSessionPath: header.parentSession,
|
|
96
|
-
created: new Date(header.timestamp ?? ''),
|
|
97
|
-
modified: lastEntryAt ?? mtime,
|
|
98
|
-
fileMtime: mtime,
|
|
99
|
-
messageCount: Math.max(parsedMessageCount, countMessageMarkers(prefix)),
|
|
100
|
-
size,
|
|
101
|
-
firstMessage: firstMessage || '(no messages)',
|
|
102
|
-
};
|
|
103
|
-
} catch {
|
|
104
|
-
return undefined;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
31
|
+
export { SESSION_TITLE_SLOT_BYTES, scanSessionInfo };
|
|
32
|
+
export type { OmpSessionInfo };
|
|
107
33
|
|
|
108
34
|
// ============================================================================
|
|
109
35
|
// File-list walk + per-file memo, both mtime-keyed
|
|
@@ -125,7 +51,7 @@ interface SessionFileListCacheEntry {
|
|
|
125
51
|
* missing a scan. Bump this whenever OmpSessionInfo changes; the cache is
|
|
126
52
|
* discarded instead of misread.
|
|
127
53
|
*/
|
|
128
|
-
const SESSION_SCAN_CACHE_VERSION =
|
|
54
|
+
const SESSION_SCAN_CACHE_VERSION = 3;
|
|
129
55
|
|
|
130
56
|
interface SessionScanCache {
|
|
131
57
|
version: number;
|
|
@@ -205,6 +131,10 @@ export async function listSessionFiles(sessionsRoot: string = getSessionsDir()):
|
|
|
205
131
|
}
|
|
206
132
|
|
|
207
133
|
cache.set(sessionsRoot, { dirMtimes, files });
|
|
134
|
+
// A rebuilt list means the directory moved (a session was added or removed),
|
|
135
|
+
// which is exactly when the `id → path` index is stale — it is derived from
|
|
136
|
+
// this list, and its own mtime key cannot see a file that is not in it yet.
|
|
137
|
+
globalThis.__ompChamberSessionIdIndex?.delete(sessionsRoot);
|
|
208
138
|
return files;
|
|
209
139
|
}
|
|
210
140
|
|
|
@@ -235,7 +165,11 @@ export async function scanSessionInfoCached(filePath: string): Promise<OmpSessio
|
|
|
235
165
|
// `modified` — the latter is now an entry timestamp, and a title-slot
|
|
236
166
|
// rewrite that bumps the file without appending an entry must still be
|
|
237
167
|
// picked up (it can change the title the row displays).
|
|
238
|
-
|
|
168
|
+
//
|
|
169
|
+
// The comparison is against the RAW `stat.mtimeMs`: `Date.getTime()`
|
|
170
|
+
// truncates the fractional millisecond APFS reports, so a Date comparison
|
|
171
|
+
// never matched and this cache never hit (measured: 451 of 452 files).
|
|
172
|
+
if (info.size === stat.size && info.fileMtimeMs === stat.mtimeMs) {
|
|
239
173
|
cache.delete(filePath);
|
|
240
174
|
cache.set(filePath, info);
|
|
241
175
|
return info;
|
|
@@ -256,6 +190,32 @@ export async function scanSessionInfoCached(filePath: string): Promise<OmpSessio
|
|
|
256
190
|
return info;
|
|
257
191
|
}
|
|
258
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Map `items` through an async `worker`, at most `limit` in flight.
|
|
195
|
+
*
|
|
196
|
+
* The session scans are I/O-bound, so a sequential loop serializes round trips
|
|
197
|
+
* the filesystem answers in parallel (measured 36 ms sequential against 12 ms
|
|
198
|
+
* for the same warm work over 452 files). The bound is what keeps an unbounded
|
|
199
|
+
* `Promise.all` from opening one descriptor per session file on a host with a
|
|
200
|
+
* low soft limit.
|
|
201
|
+
*/
|
|
202
|
+
async function mapBounded<T, R>(items: readonly T[], limit: number, worker: (item: T) => Promise<R>): Promise<R[]> {
|
|
203
|
+
const out = new Array<R>(items.length);
|
|
204
|
+
let next = 0;
|
|
205
|
+
const runners = new Array(Math.min(limit, items.length)).fill(null).map(async () => {
|
|
206
|
+
for (;;) {
|
|
207
|
+
const index = next++;
|
|
208
|
+
if (index >= items.length) return;
|
|
209
|
+
out[index] = await worker(items[index]);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
await Promise.all(runners);
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Concurrent per-file scans; each holds one descriptor while it stats/reads. */
|
|
217
|
+
const SESSION_SCAN_CONCURRENCY = 32;
|
|
218
|
+
|
|
259
219
|
/**
|
|
260
220
|
* List every session across all project subdirectories of the sessions root,
|
|
261
221
|
* newest-modified first. Invalidates are automatic via mtime keys; callers
|
|
@@ -263,26 +223,65 @@ export async function scanSessionInfoCached(filePath: string): Promise<OmpSessio
|
|
|
263
223
|
*/
|
|
264
224
|
export async function listAllSessionInfos(sessionsRoot: string = getSessionsDir()): Promise<OmpSessionInfo[]> {
|
|
265
225
|
const files = await listSessionFiles(sessionsRoot);
|
|
226
|
+
const scanned = await mapBounded(files, SESSION_SCAN_CONCURRENCY, (file) => scanSessionInfoCached(file));
|
|
266
227
|
const sessions: OmpSessionInfo[] = [];
|
|
267
|
-
for (const
|
|
268
|
-
const info = await scanSessionInfoCached(file);
|
|
228
|
+
for (const info of scanned) {
|
|
269
229
|
if (info) sessions.push(info);
|
|
270
230
|
}
|
|
271
231
|
sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
|
272
232
|
return sessions;
|
|
273
233
|
}
|
|
274
234
|
|
|
235
|
+
/**
|
|
236
|
+
* `id → path` for every session file under `sessionsRoot`, built from the same
|
|
237
|
+
* mtime-keyed scan cache the sidebar list fills.
|
|
238
|
+
*
|
|
239
|
+
* Resolving one id used to mean scanning every file until it matched —
|
|
240
|
+
* measured 34.8 ms for the 452 files on this machine, on the hot path of eight
|
|
241
|
+
* routes. The index is derived from `listAllSessionInfos`' own results, so it
|
|
242
|
+
* costs one map build and invalidates with the caches it reads.
|
|
243
|
+
*
|
|
244
|
+
* Cached per root on `globalThis` (a `bun --hot` reload keeps it) and dropped
|
|
245
|
+
* by `clearSessionFileCaches()`, which every mutation path already calls.
|
|
246
|
+
*/
|
|
247
|
+
interface SessionIdIndex {
|
|
248
|
+
entries: Map<string, string>;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
declare global {
|
|
252
|
+
var __ompChamberSessionIdIndex: Map<string, SessionIdIndex> | undefined;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** The `id → path` index for `sessionsRoot`, building it on first use. */
|
|
256
|
+
export async function sessionIdIndex(sessionsRoot: string = getSessionsDir()): Promise<Map<string, string>> {
|
|
257
|
+
const host = (globalThis.__ompChamberSessionIdIndex ??= new Map());
|
|
258
|
+
const cached = host.get(sessionsRoot);
|
|
259
|
+
if (cached) return cached.entries;
|
|
260
|
+
const entries = new Map<string, string>();
|
|
261
|
+
for (const info of await listAllSessionInfos(sessionsRoot)) {
|
|
262
|
+
// A duplicate id cannot happen (omp ids are UUIDs), but first-wins keeps
|
|
263
|
+
// the answer stable if a copied session file ever appears.
|
|
264
|
+
if (!entries.has(info.id)) entries.set(info.id, info.path);
|
|
265
|
+
}
|
|
266
|
+
host.set(sessionsRoot, { entries });
|
|
267
|
+
return entries;
|
|
268
|
+
}
|
|
269
|
+
|
|
275
270
|
/** Clear in-memory file-list and per-file scan caches (e.g. after a mutation
|
|
276
271
|
* that mtime keys cannot detect). */
|
|
277
272
|
export function clearSessionFileCaches(): void {
|
|
278
273
|
globalThis.__ompChamberSessionFileListCache?.clear();
|
|
279
274
|
globalThis.__ompChamberSessionScanCache?.entries.clear();
|
|
275
|
+
globalThis.__ompChamberSessionIdIndex?.clear();
|
|
280
276
|
}
|
|
281
277
|
|
|
282
278
|
/** Exported for tests/spot-checks: read a raw JSONL session header directly. */
|
|
283
279
|
export async function readRawHeaderLine(filePath: string): Promise<Record<string, unknown> | undefined> {
|
|
284
280
|
try {
|
|
285
|
-
|
|
281
|
+
// A bounded Blob slice, not `.text().slice(0, n)`: the header lives in the
|
|
282
|
+
// first few hundred bytes, and decoding a whole session to look at them
|
|
283
|
+
// cost 4.9 ms on an 11 MB file against 0.1 ms for the slice.
|
|
284
|
+
const head = await Bun.file(filePath).slice(0, SESSION_TITLE_SLOT_BYTES + 4096).text();
|
|
286
285
|
const lines = head.split('\n').filter(Boolean);
|
|
287
286
|
for (const line of lines) {
|
|
288
287
|
try {
|
|
@@ -11,19 +11,29 @@
|
|
|
11
11
|
|
|
12
12
|
import { json } from '@/server/lib/remix-compat';
|
|
13
13
|
import { getSessionsDir } from '@/server/lib/omp/core/paths';
|
|
14
|
-
import {
|
|
14
|
+
import { clearSessionFileCaches, readRawHeaderLine, sessionIdIndex } from '@/server/lib/omp/session/files';
|
|
15
15
|
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Locate an oh-my-pi session file by its session UUID.
|
|
18
|
+
*
|
|
19
|
+
* Resolves through the `id → path` index (files.ts), which is built from the
|
|
20
|
+
* same mtime-keyed scan cache the sidebar list fills. A linear scan over every
|
|
21
|
+
* session file measured 34.8 ms on the 452 files this machine holds — and it
|
|
22
|
+
* runs on the hot path of eight routes — against an O(1) map lookup.
|
|
23
|
+
*
|
|
24
|
+
* A miss is re-checked once against a freshly built index: a session created
|
|
25
|
+
* since the index was built would otherwise 404 until something else cleared
|
|
26
|
+
* the caches.
|
|
27
|
+
*/
|
|
17
28
|
export async function findSessionFileById(
|
|
18
29
|
sessionId: string,
|
|
19
30
|
sessionsRoot: string = getSessionsDir(),
|
|
20
31
|
): Promise<string | undefined> {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return undefined;
|
|
32
|
+
const index = await sessionIdIndex(sessionsRoot);
|
|
33
|
+
const hit = index.get(sessionId);
|
|
34
|
+
if (hit) return hit;
|
|
35
|
+
clearSessionFileCaches();
|
|
36
|
+
return (await sessionIdIndex(sessionsRoot)).get(sessionId);
|
|
27
37
|
}
|
|
28
38
|
|
|
29
39
|
/** The JSON 404 envelope every session-resolve guard answers with. */
|
|
@@ -161,13 +161,13 @@ async function resolveImageBlobs(messages: ChatMessageData[]): Promise<void> {
|
|
|
161
161
|
/** Derive the display title from the JSONL header/title slot (cheap read). */
|
|
162
162
|
export async function loadSessionTitle(filePath: string): Promise<string | undefined> {
|
|
163
163
|
try {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const head =
|
|
164
|
+
// A bounded Blob slice, and deliberately NO size guard. The title slot and
|
|
165
|
+
// the session header live in the first few hundred bytes, so the 32 KiB
|
|
166
|
+
// window is what makes the read cheap — a file-size refusal on top of it
|
|
167
|
+
// only discarded titles that were sitting right there (measured: an 11 MB
|
|
168
|
+
// session reported `title: undefined` while its title slot held
|
|
169
|
+
// "Debug file attachment read failure").
|
|
170
|
+
const head = await Bun.file(filePath).slice(0, 32 * 1024).text();
|
|
171
171
|
const records = parseJsonlLenient<Record<string, unknown>>(head);
|
|
172
172
|
const first = records[0];
|
|
173
173
|
if (first?.type === 'title' && typeof first.title === 'string' && first.title.trim()) {
|