ompchamber 1.2.1 → 2.0.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 +131 -147
- package/dist/client/index.html +4 -2
- package/dist/client/static/css/index.5f630421b7.css +2 -0
- package/dist/client/static/js/77.0d81c74cf0.js +139 -0
- package/dist/client/static/js/async/1k.973e8e21fc.js +2 -0
- package/dist/client/static/js/async/{3y.3186ecdd63.js → 3y.3530b824ad.js} +6 -6
- package/dist/client/static/js/async/{q.b453193538.js → 4m.928a1dde85.js} +10 -10
- package/dist/client/static/js/async/66.5dfa29f957.js +93 -0
- package/dist/client/static/js/async/7g.a7814350c3.js +1 -0
- package/dist/client/static/js/async/ar.9303e5f2e8.js +2 -0
- package/dist/client/static/js/async/{cx.9c8268d542.js → cx.3cb0242a3a.js} +1 -1
- package/dist/client/static/js/async/e.93214d622b.js +79 -0
- package/dist/client/static/js/async/g.74ff36419f.js +2 -0
- package/dist/client/static/js/async/{m.2e1ea3db9e.js.LICENSE.txt → g.74ff36419f.js.LICENSE.txt} +5 -0
- package/dist/client/static/js/async/h1.393f066d09.js +1 -0
- package/dist/client/static/js/async/jl.874d352278.js +1 -0
- package/dist/client/static/js/async/{k.d16cd09ae7.js → k.3210286401.js} +1 -1
- package/dist/client/static/js/async/{m.2e1ea3db9e.js → m.eb2e6752e2.js} +3 -3
- package/dist/client/static/js/async/m.eb2e6752e2.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/mt.0352e976d8.js +1 -0
- package/dist/client/static/js/async/{oc.eb22093f05.js → oc.b73042e8bd.js} +1 -1
- package/dist/client/static/js/async/{og.53f068804c.js → og.8eb577893a.js} +1 -1
- package/dist/client/static/js/async/px.fc6877924b.js +1 -0
- package/dist/client/static/js/async/q.33d6779f98.js +21 -0
- package/dist/client/static/js/async/{q5.88b50ffa95.js → q5.97dc1481b6.js} +1 -1
- package/dist/client/static/js/async/s.0f395cdda5.js +94 -0
- package/dist/client/static/js/async/{tr.2a0d2b7600.js → tr.e878658140.js} +1 -1
- package/dist/client/static/js/async/vt.42416b707d.js +1 -0
- package/dist/client/static/js/async/x1.da8687159b.js +90 -0
- package/dist/client/static/js/async/xe.da8dcc2015.js +2 -0
- package/dist/client/static/js/async/xe.da8dcc2015.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{xn.6360379f71.js → xn.6510c0abe2.js} +2 -2
- package/dist/client/static/js/async/yg.35bebd6d0c.js +149 -0
- package/dist/client/static/js/async/yv.d99d829136.js +1 -0
- package/dist/client/static/js/index.c05535dbde.js +38 -0
- package/package.json +3 -3
- package/src/client/components/common/ComposerTextarea.tsx +9 -1
- package/src/client/components/common/MarkdownRenderer.tsx +50 -12
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +9 -3
- package/src/client/components/settings/Modal.tsx +11 -2
- package/src/client/components/settings/categories/AppearanceSettings.tsx +7 -30
- package/src/client/components/settings/categories/ProviderSettings.tsx +2 -0
- package/src/client/components/settings/categories/appearance-settings/ThemeCard.tsx +61 -0
- package/src/client/components/settings/categories/appearance-settings/ThemeGrid.tsx +80 -0
- package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/AddProviderModal.tsx +43 -6
- package/src/client/components/settings/categories/provider-settings/ModelConfigModal.tsx +54 -54
- package/src/client/components/workspace/SearchPanel.tsx +16 -3
- package/src/client/components/workspace/btw-panel/AskCard.tsx +11 -6
- package/src/client/components/workspace/btw-panel/Messages.tsx +105 -51
- package/src/client/components/workspace/btw-panel/index.tsx +124 -45
- package/src/client/components/workspace/chat-timeline/AiMessageFooter.tsx +30 -14
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +10 -1
- package/src/client/components/workspace/chat-timeline/GeneratingIndicator.tsx +8 -2
- package/src/client/components/workspace/chat-timeline/MessageItem.tsx +27 -16
- package/src/client/components/workspace/chat-timeline/MessageList.tsx +11 -0
- package/src/client/components/workspace/chat-timeline/RunFooter.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +12 -1
- package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +42 -15
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +13 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/index.tsx +13 -4
- package/src/client/components/workspace/editor/index.tsx +11 -2
- package/src/client/components/workspace/file-explorer/GitRepoDropdown.tsx +15 -41
- package/src/client/components/workspace/file-explorer/index.tsx +86 -16
- package/src/client/components/workspace/git-panel/index.tsx +60 -78
- package/src/client/components/workspace/terminal-panel/Header.tsx +16 -1
- package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +5 -5
- package/src/client/components/workspace/terminal-panel/index.tsx +6 -2
- package/src/client/components/workspace/terminal-panel/mount.ts +2 -2
- package/src/client/data/theme/terminal.ts +50 -52
- package/src/client/hooks/chat/btw/index.ts +139 -18
- package/src/client/hooks/chat/btw/intercept.ts +8 -8
- package/src/client/hooks/chat/btw/mode.ts +111 -16
- package/src/client/hooks/chat/composer/index.ts +10 -4
- package/src/client/hooks/chat/timeline/access-mode.ts +41 -0
- package/src/client/hooks/chat/timeline/ask-frames.test.ts +87 -0
- package/src/client/hooks/chat/timeline/ask-frames.ts +20 -1
- package/src/client/hooks/chat/timeline/auto-scroll.ts +13 -4
- package/src/client/hooks/chat/timeline/index.ts +8 -20
- package/src/client/hooks/chat/timeline/queue.ts +66 -9
- package/src/client/hooks/chat/timeline/scroll.test.ts +260 -0
- package/src/client/hooks/chat/timeline/scroll.ts +93 -20
- package/src/client/hooks/chat/timeline/session-load.ts +7 -1
- package/src/client/hooks/chat/timeline/session-pagination.test.ts +167 -0
- package/src/client/hooks/chat/timeline/session-pagination.ts +37 -11
- package/src/client/hooks/settings/providers.ts +34 -3
- package/src/client/hooks/ui/open-file.ts +7 -1
- package/src/client/hooks/ui/theme.ts +82 -57
- package/src/client/hooks/workspace/file-tabs.ts +5 -1
- package/src/client/hooks/workspace/repo-scope/index.ts +119 -0
- package/src/client/hooks/workspace/repo-scope/store.test.ts +161 -0
- package/src/client/hooks/workspace/repo-scope/store.ts +201 -0
- package/src/client/hooks/workspace/search-stream.ts +24 -5
- package/src/client/main.tsx +6 -0
- package/src/client/tailwind.css +19 -56
- package/src/server/lib/btw/child.server.ts +41 -0
- package/src/server/lib/btw/lifecycle.server.ts +254 -0
- package/src/server/lib/btw/prompt.ts +13 -5
- package/src/server/lib/btw/registry.server.ts +13 -4
- package/src/server/lib/btw/runtime.server.ts +149 -156
- package/src/server/lib/btw/schema.server.ts +27 -0
- package/src/server/lib/btw/service.server.ts +106 -5
- package/src/server/lib/btw/store.server.ts +57 -6
- package/src/server/lib/db/schema.ts +4 -0
- package/src/server/lib/db/settings-store.test.ts +65 -0
- package/src/server/lib/db/settings-store.ts +25 -0
- package/src/server/lib/fs/atomic-write.ts +42 -4
- package/src/server/lib/fs/config-lock.ts +74 -0
- package/src/server/lib/fs/git-ignore.ts +55 -5
- package/src/server/lib/fs/ripgrep.ts +119 -0
- package/src/server/lib/lifecycle/identity.ts +10 -64
- package/src/server/lib/lifecycle/proc/darwin.ts +238 -0
- package/src/server/lib/lifecycle/proc/index.ts +38 -0
- package/src/server/lib/lifecycle/proc/linux.ts +80 -0
- package/src/server/lib/lifecycle/proc/ps.ts +86 -0
- package/src/server/lib/lifecycle/proc/types.ts +33 -0
- package/src/server/lib/lifecycle/proc/win32.ts +53 -0
- package/src/server/lib/models/provider-registry.server.ts +2 -2
- package/src/server/lib/models/registry.server.ts +1 -1
- package/src/server/lib/omp/config/behavior.ts +17 -17
- package/src/server/lib/omp/config/disabled-providers.ts +84 -35
- package/src/server/lib/omp/config/document.ts +128 -0
- package/src/server/lib/omp/config/extensions.ts +11 -10
- package/src/server/lib/omp/config/mcp.ts +4 -57
- package/src/server/lib/omp/config/model-overrides.ts +123 -0
- package/src/server/lib/omp/config/models-config.ts +92 -0
- package/src/server/lib/omp/config/models-validation.ts +159 -0
- package/src/server/lib/omp/config/path-scoped.ts +74 -0
- package/src/server/lib/omp/config/providers.ts +241 -211
- package/src/server/lib/omp/config/roles.ts +6 -14
- package/src/server/lib/omp/core/auth-credentials.ts +1 -1
- package/src/server/lib/omp/rpc/frame-fold.test.ts +124 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +43 -24
- package/src/server/lib/omp/rpc/lifecycle.test.ts +41 -0
- package/src/server/lib/omp/rpc/manager.ts +21 -21
- package/src/server/lib/omp/rpc/pending-ui-dialogs.ts +11 -7
- package/src/server/lib/omp/rpc/session-commands.ts +17 -29
- package/src/server/lib/omp/rpc/session-registry.ts +5 -66
- package/src/server/lib/omp/rpc/web-state.ts +0 -6
- package/src/server/lib/omp/session/auto-title.server.test.ts +109 -11
- package/src/server/lib/omp/session/auto-title.server.ts +95 -20
- package/src/server/lib/omp/session/sidebar-data.server.ts +11 -6
- package/src/server/lib/queue/delivery-dispatch.server.test.ts +162 -0
- package/src/server/lib/queue/delivery-harness.ts +88 -0
- package/src/server/lib/queue/delivery.server.test.ts +78 -149
- package/src/server/lib/queue/delivery.server.ts +105 -13
- package/src/server/lib/queue/store.server.ts +30 -5
- package/src/server/lib/terminal/foreground.test.ts +164 -0
- package/src/server/lib/terminal/foreground.ts +34 -6
- package/src/server/lib/terminal/history.ts +8 -7
- package/src/server/plugins/compress.ts +33 -5
- package/src/server/plugins/ssr.ts +14 -7
- package/src/server/routes/btw/topics.ts +89 -2
- package/src/server/routes/fs/git.ts +5 -6
- package/src/server/routes/fs/search.ts +17 -26
- package/src/server/routes/sessions/queue.ts +17 -4
- package/src/server/routes/settings/index.ts +2 -0
- package/src/server/routes/settings/model-override.ts +69 -0
- package/src/server/routes/settings/provider-models.ts +35 -12
- package/src/server/routes/settings/providers.ts +31 -1
- package/src/server/routes/settings/root.ts +8 -3
- package/src/shared/lib/chat/btw/client.ts +34 -0
- package/src/shared/lib/chat/timeline/tool-phrases.ts +7 -0
- package/src/shared/lib/code/shiki-themes.ts +9 -5
- package/src/shared/lib/fs/file-tree.ts +17 -12
- package/src/shared/lib/markdown/autolink.test.ts +32 -0
- package/src/shared/lib/markdown/autolink.ts +50 -0
- package/src/shared/lib/markdown/document-urls.test.ts +68 -0
- package/src/shared/lib/markdown/document-urls.ts +118 -0
- package/src/shared/lib/markdown/marked.ts +27 -15
- package/src/shared/lib/markdown/mermaid.ts +2 -2
- package/src/shared/lib/models/catalog.ts +1 -1
- package/src/shared/lib/omp/session/schema-migrations.ts +30 -0
- package/src/shared/lib/omp/session/stream-state.server.test.ts +54 -0
- package/src/shared/lib/omp/session/stream-state.server.ts +70 -21
- package/src/shared/lib/settings/client.ts +10 -4
- package/src/shared/lib/settings/diff.test.ts +31 -0
- package/src/shared/lib/settings/diff.ts +37 -0
- package/src/shared/lib/theme/catalog.test.ts +168 -0
- package/src/shared/lib/theme/catalog.ts +98 -0
- package/src/shared/lib/theme/color.ts +88 -0
- package/src/shared/lib/theme/css.ts +70 -0
- package/src/shared/lib/theme/palettes/chamber.ts +61 -0
- package/src/shared/lib/theme/palettes/dark.ts +313 -0
- package/src/shared/lib/theme/palettes/light.ts +313 -0
- package/src/shared/types/btw.ts +83 -4
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/settings/provider.ts +8 -1
- package/src/shared/types/settings/state.ts +10 -4
- package/src/shared/types/theme.ts +48 -0
- package/dist/client/static/css/index.b938f4a693.css +0 -2
- package/dist/client/static/js/77.8022a800b5.js +0 -139
- package/dist/client/static/js/async/1k.a411fe5b43.js +0 -2
- package/dist/client/static/js/async/66.86544ad741.js +0 -93
- package/dist/client/static/js/async/7g.db82aca7ba.js +0 -1
- package/dist/client/static/js/async/ar.ad5c8822a8.js +0 -2
- package/dist/client/static/js/async/e.6443e739a2.js +0 -79
- package/dist/client/static/js/async/g.ee41bfed7f.js +0 -2
- package/dist/client/static/js/async/h1.d7091cef78.js +0 -1
- package/dist/client/static/js/async/jl.4911fa3383.js +0 -1
- package/dist/client/static/js/async/mt.853be2b5aa.js +0 -1
- package/dist/client/static/js/async/ot.84c71b308f.js +0 -2
- package/dist/client/static/js/async/px.783b6f91f2.js +0 -1
- package/dist/client/static/js/async/q8.7f1b48e9cd.js +0 -21
- package/dist/client/static/js/async/q8.7f1b48e9cd.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/s.aad2ecebc6.js +0 -94
- package/dist/client/static/js/async/vt.20406a5604.js +0 -1
- package/dist/client/static/js/async/x1.fe8af17016.js +0 -90
- package/dist/client/static/js/async/xn.6360379f71.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/yg.5a94e1fc8a.js +0 -149
- package/dist/client/static/js/async/yv.2fa22f009a.js +0 -1
- package/dist/client/static/js/index.5fe7a2cb64.js +0 -34
- package/src/client/components/workspace/btw-panel/Composer.tsx +0 -183
- package/src/client/components/workspace/btw-panel/attachments.ts +0 -80
- /package/dist/client/static/js/{77.8022a800b5.js.LICENSE.txt → 77.0d81c74cf0.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.ee41bfed7f.js.LICENSE.txt → 1k.973e8e21fc.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{3y.3186ecdd63.js.LICENSE.txt → 3y.3530b824ad.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{ot.84c71b308f.js.LICENSE.txt → 4m.928a1dde85.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.b453193538.js.LICENSE.txt → ar.9303e5f2e8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{1k.a411fe5b43.js.LICENSE.txt → q.33d6779f98.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{ar.ad5c8822a8.js.LICENSE.txt → xn.6510c0abe2.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.5fe7a2cb64.js.LICENSE.txt → index.c05535dbde.js.LICENSE.txt} +0 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `detectBusyShells` decides the terminal cap's budget, so it has to be right
|
|
8
|
+
* about the two states that matter: a shell at its prompt (not counted) and a
|
|
9
|
+
* shell running a command (counted). Both are observed against a real PTY,
|
|
10
|
+
* because the whole question is what the *kernel* says the terminal's
|
|
11
|
+
* foreground group is — a mocked probe would only test the mock.
|
|
12
|
+
*
|
|
13
|
+
* The probe's answer is cross-checked against `ps -o tpgid=`, which is the
|
|
14
|
+
* definition of the field being read: an offset that drifted makes the probe
|
|
15
|
+
* return null (its own pid guard rejects a mismatched struct) and fail these
|
|
16
|
+
* assertions rather than silently report a garbage group.
|
|
17
|
+
*
|
|
18
|
+
* Waiting is driven by the PTY stream, not by sleeps: a shell's prompt (or the
|
|
19
|
+
* marker a command prints before it parks) is the real signal that the
|
|
20
|
+
* foreground group has moved, so the assertions never race a guessed delay.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { afterAll, describe, expect, test } from 'bun:test';
|
|
24
|
+
|
|
25
|
+
import { processProbe } from '@/server/lib/lifecycle/proc';
|
|
26
|
+
import { shellLaunch } from '@/server/lib/terminal/shell';
|
|
27
|
+
import { detectBusyShells, type ShellProcess } from '@/server/lib/terminal/foreground';
|
|
28
|
+
|
|
29
|
+
const describePosix = process.platform === 'win32' ? describe.skip : describe;
|
|
30
|
+
|
|
31
|
+
/** The shell the test drives, and the prompt it announces itself with. */
|
|
32
|
+
const SHELL = '/bin/sh';
|
|
33
|
+
/**
|
|
34
|
+
* Spelled `CHAM''BER` on the command line below: the PTY echoes what is typed,
|
|
35
|
+
* so a literal needle would be satisfied by the echo before the shell ever
|
|
36
|
+
* parsed the line — the same trap the busy marker avoids.
|
|
37
|
+
*/
|
|
38
|
+
const PROMPT = 'CHAMBER> ';
|
|
39
|
+
|
|
40
|
+
/** The value `ps` prints for the same field the probe reads. */
|
|
41
|
+
async function psTpgid(pid: number): Promise<number> {
|
|
42
|
+
const proc = Bun.spawn(['ps', '-o', 'tpgid=', '-p', String(pid)], { stdout: 'pipe', stderr: 'ignore' });
|
|
43
|
+
const text = await new Response(proc.stdout).text();
|
|
44
|
+
await proc.exited;
|
|
45
|
+
return Number(text.trim());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface PtyShell {
|
|
49
|
+
id: string;
|
|
50
|
+
pid: number;
|
|
51
|
+
term: Bun.Terminal;
|
|
52
|
+
proc: Bun.Subprocess;
|
|
53
|
+
/** Resolves the next time the PTY emits `needle`; output seen so far counts. */
|
|
54
|
+
until: (needle: string) => Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const spawned: PtyShell[] = [];
|
|
58
|
+
|
|
59
|
+
async function spawnShell(id: string): Promise<PtyShell> {
|
|
60
|
+
let seen = '';
|
|
61
|
+
let pending: { needle: string; resolve: () => void } | null = null;
|
|
62
|
+
const term = new Bun.Terminal({
|
|
63
|
+
cols: 80,
|
|
64
|
+
rows: 24,
|
|
65
|
+
name: 'xterm-256color',
|
|
66
|
+
data(_term, bytes) {
|
|
67
|
+
seen += new TextDecoder().decode(bytes);
|
|
68
|
+
if (pending && seen.includes(pending.needle)) {
|
|
69
|
+
const { resolve } = pending;
|
|
70
|
+
pending = null;
|
|
71
|
+
resolve();
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
// The production launch argv, not a bare `/bin/sh`. The runtime must spawn
|
|
76
|
+
// `detached: true`, and `setsid()` drops the controlling terminal — a shell
|
|
77
|
+
// that never re-opens the PTY reports `tpgid` as -1 (verified on Linux), which
|
|
78
|
+
// reads as "a command owns the terminal" and is what made the prompt
|
|
79
|
+
// assertion below fail there. `shellLaunch` is the shim that re-acquires it,
|
|
80
|
+
// so this is the shell shape the probe is actually asked about.
|
|
81
|
+
const proc = Bun.spawn(shellLaunch(SHELL), { terminal: term, detached: true, cwd: '/tmp' });
|
|
82
|
+
const shell: PtyShell = {
|
|
83
|
+
id,
|
|
84
|
+
pid: proc.pid,
|
|
85
|
+
term,
|
|
86
|
+
proc,
|
|
87
|
+
until(needle) {
|
|
88
|
+
if (seen.includes(needle)) return Promise.resolve();
|
|
89
|
+
return new Promise<void>((resolve) => {
|
|
90
|
+
pending = { needle, resolve };
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
spawned.push(shell);
|
|
95
|
+
return shell;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
afterAll(async () => {
|
|
99
|
+
for (const shell of spawned) {
|
|
100
|
+
try {
|
|
101
|
+
shell.term.write('\u0003exit\n');
|
|
102
|
+
} catch {
|
|
103
|
+
// Already closed.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
await Promise.all(spawned.map((shell) => shell.proc.exited.catch(() => undefined)));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describePosix('processProbe.foregroundGroup', () => {
|
|
110
|
+
test('reports the same group ps does for a live pid', async () => {
|
|
111
|
+
const fromProbe = processProbe.foregroundGroup(process.pid);
|
|
112
|
+
expect(fromProbe).not.toBeNull();
|
|
113
|
+
expect(fromProbe).toBe(await psTpgid(process.pid));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('returns null for a pid that no longer exists', async () => {
|
|
117
|
+
const corpse = Bun.spawn(['/bin/sh', '-c', 'exit 0']);
|
|
118
|
+
const deadPid = corpse.pid;
|
|
119
|
+
await corpse.exited;
|
|
120
|
+
expect(processProbe.foregroundGroup(deadPid)).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describePosix('detectBusyShells', () => {
|
|
125
|
+
test('a shell at its prompt is not busy, and one running a command is', async () => {
|
|
126
|
+
const shell = await spawnShell('probe');
|
|
127
|
+
const shells: ShellProcess[] = [{ id: shell.id, pid: shell.pid }];
|
|
128
|
+
|
|
129
|
+
// The prompt is the shell printing from its own foreground group. It is
|
|
130
|
+
// named by us rather than assumed: the default is `$ ` for a user and `# `
|
|
131
|
+
// for root, and the assertion is about the foreground group, not the glyph.
|
|
132
|
+
shell.term.write("PS1='CHAM''BER> '\n");
|
|
133
|
+
await shell.until(PROMPT);
|
|
134
|
+
expect((await detectBusyShells(shells)).has(shell.id)).toBe(false);
|
|
135
|
+
|
|
136
|
+
// The marker is printed by a child that already owns the terminal, and the
|
|
137
|
+
// typed text spells it `REA''DY` — a literal needle would be matched by the
|
|
138
|
+
// PTY's own echo of the input, before the shell had even parsed the line.
|
|
139
|
+
shell.term.write("sh -c 'echo REA''DY; sleep 30'\n");
|
|
140
|
+
await shell.until('READY\r\n');
|
|
141
|
+
expect((await detectBusyShells(shells)).has(shell.id)).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('falls back to ps when one pid cannot be probed, and still answers', async () => {
|
|
145
|
+
const shell = await spawnShell('fallback');
|
|
146
|
+
const corpse = Bun.spawn(['/bin/sh', '-c', 'exit 0']);
|
|
147
|
+
const ghostPid = corpse.pid;
|
|
148
|
+
await corpse.exited;
|
|
149
|
+
|
|
150
|
+
shell.term.write("sh -c 'echo REA''DY; sleep 30'\n");
|
|
151
|
+
await shell.until('READY\r\n');
|
|
152
|
+
|
|
153
|
+
// A dead pid is one the probe declines, which routes the whole set through
|
|
154
|
+
// `ps` — the path the BSDs take for every question.
|
|
155
|
+
const shells: ShellProcess[] = [
|
|
156
|
+
{ id: shell.id, pid: shell.pid },
|
|
157
|
+
{ id: 'ghost', pid: ghostPid },
|
|
158
|
+
];
|
|
159
|
+
const busy = await detectBusyShells(shells);
|
|
160
|
+
expect(busy.has(shell.id)).toBe(true);
|
|
161
|
+
expect(busy.has('ghost')).toBe(false);
|
|
162
|
+
expect(busy.size).toBe(1);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -19,11 +19,19 @@
|
|
|
19
19
|
* switched a few times pins the budget with shells that are all idle at a
|
|
20
20
|
* prompt, and the next attach is rejected with nothing actually running.
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* The value comes from the platform's `ProcessProbe` — `libproc` on macOS,
|
|
23
|
+
* `/proc` on Linux — which is a synchronous read of a field the kernel already
|
|
24
|
+
* has, and one `ps` subprocess answered it before. Measured on 8 live shells:
|
|
25
|
+
* 30 ms for the spawn against 0.07 ms for the probe. `ps` stays as the fallback
|
|
26
|
+
* for the platforms with no probe (the BSDs) and for the case where a probe
|
|
27
|
+
* declines to answer, so the answer never depends on the fast path working.
|
|
28
|
+
*
|
|
29
|
+
* Advisory either way: any failure resolves to "not busy", because refusing a
|
|
30
|
+
* shell to save a slot is worse than letting one extra through.
|
|
25
31
|
*/
|
|
26
32
|
|
|
33
|
+
import { processProbe } from '@/server/lib/lifecycle/proc';
|
|
34
|
+
|
|
27
35
|
const PS_TIMEOUT_MS = 1500;
|
|
28
36
|
|
|
29
37
|
/** `pid` is the shell's process (it is the PTY session leader, so pgid === pid). */
|
|
@@ -32,10 +40,15 @@ export interface ShellProcess {
|
|
|
32
40
|
pid: number;
|
|
33
41
|
}
|
|
34
42
|
|
|
35
|
-
|
|
43
|
+
/**
|
|
44
|
+
* The same question through `ps`, for a platform whose probe declined.
|
|
45
|
+
*
|
|
46
|
+
* Kept whole rather than merged into the probe path: it is the only mechanism
|
|
47
|
+
* that works everywhere a `ps` is installed, and its subprocess machinery
|
|
48
|
+
* (timeout, kill, reaping) is what the probe exists to avoid paying for.
|
|
49
|
+
*/
|
|
50
|
+
async function busyShellsViaPs(shells: ShellProcess[]): Promise<Set<string>> {
|
|
36
51
|
const busy = new Set<string>();
|
|
37
|
-
if (shells.length === 0 || process.platform === 'win32') return busy;
|
|
38
|
-
|
|
39
52
|
const byPid = new Map<number, string>();
|
|
40
53
|
for (const shell of shells) byPid.set(shell.pid, shell.id);
|
|
41
54
|
|
|
@@ -71,3 +84,18 @@ export async function detectBusyShells(shells: ShellProcess[]): Promise<Set<stri
|
|
|
71
84
|
}
|
|
72
85
|
return busy;
|
|
73
86
|
}
|
|
87
|
+
|
|
88
|
+
export async function detectBusyShells(shells: ShellProcess[]): Promise<Set<string>> {
|
|
89
|
+
const busy = new Set<string>();
|
|
90
|
+
if (shells.length === 0 || process.platform === 'win32') return busy;
|
|
91
|
+
|
|
92
|
+
// All-or-nothing: one shell the probe cannot answer for sends the whole set
|
|
93
|
+
// through `ps`, which keeps a single answer for a single question instead of
|
|
94
|
+
// mixing two sources whose failure modes differ.
|
|
95
|
+
for (const shell of shells) {
|
|
96
|
+
const tpgid = processProbe.foregroundGroup(shell.pid);
|
|
97
|
+
if (tpgid === null) return busyShellsViaPs(shells);
|
|
98
|
+
if (tpgid !== shell.pid) busy.add(shell.id);
|
|
99
|
+
}
|
|
100
|
+
return busy;
|
|
101
|
+
}
|
|
@@ -93,18 +93,19 @@ export function trimUtf8ToBytes(bytes: Uint8Array, maxBytes: number): Uint8Array
|
|
|
93
93
|
/**
|
|
94
94
|
* Split `input` into the bytes that may be replayed and the trailing fragment
|
|
95
95
|
* of an unfinished control sequence. Dropped sequences are queries only.
|
|
96
|
+
*
|
|
97
|
+
* The scan for the next ESC is `indexOf`, not a byte-at-a-time loop: this runs
|
|
98
|
+
* over the whole replay buffer, and a JS loop spends its time on the bytes
|
|
99
|
+
* between sequences rather than on the sequences themselves. Measured on
|
|
100
|
+
* 512 KiB: 0.2 ms scanning byte-by-byte against 0.0085 ms with `indexOf`.
|
|
96
101
|
*/
|
|
97
102
|
export function sanitizeReplayChunk(input: Uint8Array): { kept: Uint8Array; rest: Uint8Array } {
|
|
98
103
|
const parts: Uint8Array[] = [];
|
|
99
104
|
let from = 0;
|
|
100
|
-
let i = 0;
|
|
101
105
|
let dropped = false;
|
|
102
106
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
i += 1;
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
107
|
+
let i = input.indexOf(ESC);
|
|
108
|
+
while (i !== -1) {
|
|
108
109
|
const scan = scanEscape(input, i);
|
|
109
110
|
if (scan === null) {
|
|
110
111
|
if (i > from) parts.push(input.subarray(from, i));
|
|
@@ -122,7 +123,7 @@ export function sanitizeReplayChunk(input: Uint8Array): { kept: Uint8Array; rest
|
|
|
122
123
|
from = scan.end;
|
|
123
124
|
dropped = true;
|
|
124
125
|
}
|
|
125
|
-
i = scan.end;
|
|
126
|
+
i = input.indexOf(ESC, scan.end);
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
if (!dropped) return { kept: input, rest: EMPTY };
|
|
@@ -11,15 +11,43 @@ import { brotliCompressSync, constants } from 'node:zlib';
|
|
|
11
11
|
* The incoming body is buffered and a fresh `Response` is returned, because a
|
|
12
12
|
* `Response` body can only be read once — reading it here would otherwise
|
|
13
13
|
* leave Elysia nothing to send.
|
|
14
|
+
*
|
|
15
|
+
* Encodings are offered best-first: zstd, brotli, gzip. On a 905 KiB JSON body
|
|
16
|
+
* the three measured 0.48 ms / 8 KiB, 1.05 ms / 7 KiB and 2.22 ms / 19 KiB —
|
|
17
|
+
* zstd is both the fastest and, within 14% of brotli's ratio, effectively as
|
|
18
|
+
* small, and it stays ahead at every payload size measured (0.004 ms against
|
|
19
|
+
* 0.007 ms for gzip at 2 KiB). Brotli remains for a client that accepts `br`
|
|
20
|
+
* but not `zstd`, and gzip is the floor every client has.
|
|
14
21
|
*/
|
|
15
22
|
const MIN_BYTES = 1024;
|
|
16
23
|
|
|
17
24
|
const COMPRESSIBLE = /^(text\/|application\/(json|javascript|xml|wasm|svg))/;
|
|
18
25
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
/** Compression encodings this server can produce, best first. */
|
|
27
|
+
const ENCODINGS = ['zstd', 'br', 'gzip'] as const;
|
|
28
|
+
|
|
29
|
+
type Encoding = (typeof ENCODINGS)[number];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The best encoding the client accepts, or null.
|
|
33
|
+
*
|
|
34
|
+
* Plain substring matching, matching the previous `includes('br')` behavior: a
|
|
35
|
+
* browser's `Accept-Encoding` is an explicit set of alternatives, not a
|
|
36
|
+
* preference ordering this server has to weigh.
|
|
37
|
+
*/
|
|
38
|
+
function negotiateEncoding(acceptEncoding: string): Encoding | null {
|
|
39
|
+
for (const encoding of ENCODINGS) {
|
|
40
|
+
if (acceptEncoding.includes(encoding)) return encoding;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function compress(body: Uint8Array<ArrayBuffer>, encoding: Encoding): Uint8Array {
|
|
46
|
+
if (encoding === 'zstd') return Bun.zstdCompressSync(body, { level: 3 });
|
|
47
|
+
if (encoding === 'br') {
|
|
48
|
+
return brotliCompressSync(body, { params: { [constants.BROTLI_PARAM_QUALITY]: 4 } });
|
|
49
|
+
}
|
|
50
|
+
return Bun.gzipSync(body, { level: 6 });
|
|
23
51
|
}
|
|
24
52
|
|
|
25
53
|
export async function maybeCompress(response: Response, acceptEncoding: string | null): Promise<Response> {
|
|
@@ -29,7 +57,7 @@ export async function maybeCompress(response: Response, acceptEncoding: string |
|
|
|
29
57
|
if (!COMPRESSIBLE.test(contentType)) return response;
|
|
30
58
|
if (response.status === 204 || response.status === 304) return response;
|
|
31
59
|
|
|
32
|
-
const encoding =
|
|
60
|
+
const encoding = negotiateEncoding(acceptEncoding);
|
|
33
61
|
if (!encoding) return response;
|
|
34
62
|
|
|
35
63
|
const buffer = new Uint8Array(await response.arrayBuffer()) as Uint8Array<ArrayBuffer>;
|
|
@@ -3,6 +3,8 @@ import { join } from 'path';
|
|
|
3
3
|
import { getDb } from '@/server/db.server';
|
|
4
4
|
import { tryServeStatic } from '@/server/plugins/static';
|
|
5
5
|
import { tryProxyDevAsset } from '@/server/plugins/dev-assets';
|
|
6
|
+
import { resolveTheme } from '@/shared/lib/theme/catalog';
|
|
7
|
+
import { THEME_STYLE_ELEMENT_ID, themeStyleSheet } from '@/shared/lib/theme/css';
|
|
6
8
|
|
|
7
9
|
const CLIENT_INDEX = join(process.cwd(), 'dist/client/index.html');
|
|
8
10
|
|
|
@@ -58,10 +60,6 @@ async function readSettings(): Promise<Record<string, unknown>> {
|
|
|
58
60
|
}
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
function themeColor(theme: string): string {
|
|
62
|
-
return theme === 'one-dark-pro-soft' ? '#282c34' : '#faf8f3';
|
|
63
|
-
}
|
|
64
|
-
|
|
65
63
|
const MOBILE_UA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i;
|
|
66
64
|
|
|
67
65
|
/**
|
|
@@ -111,16 +109,25 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
|
|
|
111
109
|
|
|
112
110
|
const settings = await readSettings();
|
|
113
111
|
const chamberSettings = (settings.omp_chamber_settings ?? {}) as { theme?: string };
|
|
114
|
-
|
|
112
|
+
// Resolved through the catalog: an id this build no longer ships (a
|
|
113
|
+
// downgrade, the retired `noir` alias) renders the default palette instead of
|
|
114
|
+
// an unstyled page.
|
|
115
|
+
const theme = resolveTheme(chamberSettings.theme);
|
|
115
116
|
const initialIsMobile = MOBILE_UA.test(request.headers.get('user-agent') ?? '');
|
|
116
117
|
|
|
117
118
|
const template = await readTemplate();
|
|
118
119
|
const bootstrap = JSON.stringify({ initialIsMobile, appSettings: settings }).replace(/</g, '\\u003c');
|
|
120
|
+
// The template's own `<meta name="theme-color">` is REWRITTEN, not joined by
|
|
121
|
+
// a second tag: with both present Chrome reads the first one, so an appended
|
|
122
|
+
// tag would leave a dark theme painting light browser chrome.
|
|
123
|
+
const themeStyle = `<style id="${THEME_STYLE_ELEMENT_ID}">${themeStyleSheet()}</style>`;
|
|
119
124
|
|
|
120
125
|
return new Response(
|
|
121
126
|
template
|
|
122
|
-
.replace('data-theme="paper"', `data-theme="${theme}"`)
|
|
123
|
-
.replace('
|
|
127
|
+
.replace('data-theme="paper"', `data-theme="${theme.id}"`)
|
|
128
|
+
.replace('data-theme-variant="light"', `data-theme-variant="${theme.variant}"`)
|
|
129
|
+
.replace('<meta name="theme-color" content="#faf8f3" />', `<meta name="theme-color" content="${theme.canvas}" />`)
|
|
130
|
+
.replace('<!--app-head-->', themeStyle)
|
|
124
131
|
.replace('<!--app-bootstrap-->', `<script>window.__OMP_BOOTSTRAP__=${bootstrap}</script>`),
|
|
125
132
|
{ headers: HTML_HEADERS },
|
|
126
133
|
);
|
|
@@ -14,8 +14,20 @@
|
|
|
14
14
|
|
|
15
15
|
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from '@/server/lib/remix-compat';
|
|
16
16
|
import { toImageContents, validateAgentImages } from '@/server/lib/omp/rpc/constants';
|
|
17
|
+
import { isApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
18
|
+
import type { AttachedTextFileData } from '@/shared/lib/chat/attachments';
|
|
17
19
|
import { BtwError, type BtwImages } from '@/server/lib/btw/runtime.server';
|
|
18
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
abortBtw,
|
|
22
|
+
askBtw,
|
|
23
|
+
getBtwState,
|
|
24
|
+
promoteBtw,
|
|
25
|
+
removeBtw,
|
|
26
|
+
respondBtwDialog,
|
|
27
|
+
setBtwApprovalMode,
|
|
28
|
+
setBtwModel,
|
|
29
|
+
setBtwThinkingLevel,
|
|
30
|
+
} from '@/server/lib/btw/service.server';
|
|
19
31
|
|
|
20
32
|
/** Actions that can reject for a reason the client should show as-is. */
|
|
21
33
|
const STATUS_BY_CODE: Record<string, number> = {
|
|
@@ -39,6 +51,40 @@ function readImages(raw: unknown): { images?: BtwImages } | { error: string } {
|
|
|
39
51
|
return images ? { images } : {};
|
|
40
52
|
}
|
|
41
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Text files the client inlined for this question. Validated for shape only:
|
|
56
|
+
* the payload is a string the composer produced from bytes it already read, and
|
|
57
|
+
* the prompt builder escapes it into a fenced block.
|
|
58
|
+
*/
|
|
59
|
+
function readTextFiles(raw: unknown): AttachedTextFileData[] {
|
|
60
|
+
if (!Array.isArray(raw)) return [];
|
|
61
|
+
return raw.flatMap((entry) => {
|
|
62
|
+
if (!entry || typeof entry !== 'object') return [];
|
|
63
|
+
const file = entry as Partial<AttachedTextFileData>;
|
|
64
|
+
if (typeof file.name !== 'string' || typeof file.content !== 'string') return [];
|
|
65
|
+
return [{
|
|
66
|
+
name: file.name,
|
|
67
|
+
content: file.content,
|
|
68
|
+
mimeType: typeof file.mimeType === 'string' ? file.mimeType : 'text/plain',
|
|
69
|
+
size: typeof file.size === 'number' ? file.size : file.content.length,
|
|
70
|
+
}];
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The response body for one dialog, in the shape omp's RPC accepts: an answer
|
|
76
|
+
* value, a confirmation, or a cancellation. Anything else is refused rather
|
|
77
|
+
* than forwarded — a malformed response would leave the child blocked.
|
|
78
|
+
*/
|
|
79
|
+
function readDialogResponse(raw: unknown): Record<string, unknown> | null {
|
|
80
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
81
|
+
const response = raw as { value?: unknown; confirmed?: unknown; cancelled?: unknown };
|
|
82
|
+
if (response.cancelled === true) return { cancelled: true };
|
|
83
|
+
if (typeof response.confirmed === 'boolean') return { confirmed: response.confirmed };
|
|
84
|
+
if (typeof response.value === 'string') return { value: response.value };
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
42
88
|
export async function action({ params, request }: ActionFunctionArgs) {
|
|
43
89
|
const { sessionId } = params;
|
|
44
90
|
if (!sessionId) return json({ error: 'session id is required' }, { status: 400 });
|
|
@@ -54,12 +100,53 @@ export async function action({ params, request }: ActionFunctionArgs) {
|
|
|
54
100
|
const question = typeof body?.question === 'string' ? body.question : '';
|
|
55
101
|
const parsed = readImages(body?.images);
|
|
56
102
|
if ('error' in parsed) return json({ error: parsed.error, code: 'invalid_images' }, { status: 400 });
|
|
57
|
-
|
|
103
|
+
const provider = typeof body?.provider === 'string' ? body.provider : '';
|
|
104
|
+
const modelId = typeof body?.modelId === 'string' ? body.modelId : '';
|
|
105
|
+
return json({
|
|
106
|
+
success: true,
|
|
107
|
+
data: await askBtw(sessionId, {
|
|
108
|
+
topicId,
|
|
109
|
+
question,
|
|
110
|
+
images: parsed.images,
|
|
111
|
+
textFiles: readTextFiles(body?.textFiles),
|
|
112
|
+
...(provider && modelId ? { model: { provider, id: modelId } } : {}),
|
|
113
|
+
...(typeof body?.thinkingLevel === 'string' ? { thinkingLevel: body.thinkingLevel } : {}),
|
|
114
|
+
...(isApprovalMode(body?.accessMode) ? { approvalMode: body.accessMode } : {}),
|
|
115
|
+
}),
|
|
116
|
+
});
|
|
58
117
|
}
|
|
59
118
|
case 'abort': {
|
|
60
119
|
if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
|
|
61
120
|
return json({ success: true, data: await abortBtw(sessionId, topicId) });
|
|
62
121
|
}
|
|
122
|
+
case 'set_model': {
|
|
123
|
+
if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
|
|
124
|
+
const provider = typeof body?.provider === 'string' ? body.provider : '';
|
|
125
|
+
const modelId = typeof body?.modelId === 'string' ? body.modelId : '';
|
|
126
|
+
if (!provider || !modelId) return json({ error: 'provider and modelId are required', code: 'model_required' }, { status: 400 });
|
|
127
|
+
return json({ success: true, data: await setBtwModel(sessionId, topicId, provider, modelId) });
|
|
128
|
+
}
|
|
129
|
+
case 'set_thinking_level': {
|
|
130
|
+
if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
|
|
131
|
+
const level = typeof body?.level === 'string' ? body.level : '';
|
|
132
|
+
if (!level) return json({ error: 'level is required', code: 'level_required' }, { status: 400 });
|
|
133
|
+
return json({ success: true, data: await setBtwThinkingLevel(sessionId, topicId, level) });
|
|
134
|
+
}
|
|
135
|
+
case 'set_access_mode': {
|
|
136
|
+
if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
|
|
137
|
+
if (!isApprovalMode(body?.accessMode)) {
|
|
138
|
+
return json({ error: 'accessMode must be always-ask, write or yolo', code: 'invalid_access_mode' }, { status: 400 });
|
|
139
|
+
}
|
|
140
|
+
return json({ success: true, data: await setBtwApprovalMode(sessionId, topicId, body.accessMode) });
|
|
141
|
+
}
|
|
142
|
+
case 'dialog_response': {
|
|
143
|
+
if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
|
|
144
|
+
const id = typeof body?.id === 'string' ? body.id : '';
|
|
145
|
+
if (!id) return json({ error: 'id is required', code: 'dialog_required' }, { status: 400 });
|
|
146
|
+
const response = readDialogResponse(body?.response);
|
|
147
|
+
if (!response) return json({ error: 'a response value is required', code: 'dialog_required' }, { status: 400 });
|
|
148
|
+
return json({ success: true, data: await respondBtwDialog(sessionId, topicId, id, response) });
|
|
149
|
+
}
|
|
63
150
|
case 'promote': {
|
|
64
151
|
if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
|
|
65
152
|
return json({ success: true, data: await promoteBtw(sessionId, topicId) });
|
|
@@ -181,14 +181,16 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|
|
181
181
|
await remoteRefresh;
|
|
182
182
|
const syncCount = syncRequested ? await gitSyncCount(targetDir) : undefined;
|
|
183
183
|
|
|
184
|
+
// No `repos`/`reposPending`/`activeRepo` here: the repo list travels on the
|
|
185
|
+
// `?reposOnly=1` branch, keyed to the root it was discovered for. Echoing a
|
|
186
|
+
// repo back in a status response is what let a panel adopt a repo it had
|
|
187
|
+
// never chosen — and adopt it under whichever workspace was active when the
|
|
188
|
+
// echo arrived.
|
|
184
189
|
return json({
|
|
185
190
|
changes,
|
|
186
191
|
branch: branch || 'main',
|
|
187
192
|
branches: branches.length ? branches : ['main'],
|
|
188
193
|
remoteBranches,
|
|
189
|
-
repos,
|
|
190
|
-
reposPending: pending,
|
|
191
|
-
activeRepo: repo,
|
|
192
194
|
syncCount,
|
|
193
195
|
});
|
|
194
196
|
} catch (error: any) {
|
|
@@ -197,9 +199,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|
|
197
199
|
branch: 'main',
|
|
198
200
|
branches: ['main'],
|
|
199
201
|
remoteBranches: [],
|
|
200
|
-
repos: repos.length ? repos : ['.'],
|
|
201
|
-
reposPending: pending,
|
|
202
|
-
activeRepo: repo,
|
|
203
202
|
syncCount: { ahead: 0, behind: 0 },
|
|
204
203
|
error: error?.message || 'Git error',
|
|
205
204
|
}, { status: 200 });
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { json, type ActionFunctionArgs } from '@/server/lib/remix-compat';
|
|
2
|
-
import { ripgrep } from 'ripgrep';
|
|
3
2
|
import { isMockMode } from '@/server/mock.server';
|
|
4
3
|
import { getDefaultFsRoot, resolveRoot } from '@/server/lib/fs/root';
|
|
5
4
|
import { scopeToRepo } from '@/server/lib/fs/repo-scope';
|
|
5
|
+
import { runRipgrep } from '@/server/lib/fs/ripgrep';
|
|
6
6
|
import { createSseStream } from '@/server/lib/sse';
|
|
7
7
|
|
|
8
8
|
export interface SearchMatch {
|
|
@@ -86,38 +86,29 @@ export async function action({ request }: ActionFunctionArgs) {
|
|
|
86
86
|
async onStart(handlers) {
|
|
87
87
|
const send = (event: string, data: unknown) => handlers.send(event, data);
|
|
88
88
|
|
|
89
|
-
// WASI preopens map the guest "." onto the real target directory.
|
|
90
|
-
const preopens = { '.': targetDir };
|
|
91
89
|
const decoder = new TextDecoder();
|
|
92
90
|
let pending = '';
|
|
93
91
|
let count = 0;
|
|
94
92
|
let stderrText = '';
|
|
95
93
|
|
|
96
94
|
try {
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (batch.length) {
|
|
112
|
-
count += batch.length;
|
|
113
|
-
send('matches', batch);
|
|
114
|
-
}
|
|
115
|
-
},
|
|
95
|
+
const code = await runRipgrep(buildArgs(q, matchCase, wholeWord, useRegex, includeFiles), targetDir, {
|
|
96
|
+
onStdout(chunk) {
|
|
97
|
+
pending += decoder.decode(chunk, { stream: true });
|
|
98
|
+
const lines = pending.split('\n');
|
|
99
|
+
pending = lines.pop() ?? '';
|
|
100
|
+
const batch: SearchMatch[] = [];
|
|
101
|
+
for (const line of lines) {
|
|
102
|
+
const match = parseRgLine(line);
|
|
103
|
+
if (match) batch.push(match);
|
|
104
|
+
}
|
|
105
|
+
if (batch.length) {
|
|
106
|
+
count += batch.length;
|
|
107
|
+
send('matches', batch);
|
|
108
|
+
}
|
|
116
109
|
},
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
stderrText += decoder.decode(chunk, { stream: true });
|
|
120
|
-
},
|
|
110
|
+
onStderr(chunk) {
|
|
111
|
+
stderrText += decoder.decode(chunk, { stream: true });
|
|
121
112
|
},
|
|
122
113
|
});
|
|
123
114
|
// rg exit codes: 0 = matches, 1 = no matches, 2 = error (bad regex, IO failure).
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
reorderQueue,
|
|
23
23
|
} from '@/server/lib/queue/store.server';
|
|
24
24
|
import { getRpcSession } from '@/server/lib/omp/rpc/manager';
|
|
25
|
-
import {
|
|
25
|
+
import { deliverQueueNow } from '@/server/lib/queue/delivery.server';
|
|
26
26
|
|
|
27
27
|
/** GET — the session's queued follow-ups in delivery order. */
|
|
28
28
|
export async function getQueue({ params }: LoaderFunctionArgs) {
|
|
@@ -108,6 +108,17 @@ export async function reorderQueueItems({ request, params }: ActionFunctionArgs)
|
|
|
108
108
|
* the server a delivery slot. The server-side claim makes this a no-op when a
|
|
109
109
|
* run is active or the head was already claimed; an idle session with queued
|
|
110
110
|
* items gets its head delivered exactly once.
|
|
111
|
+
*
|
|
112
|
+
* The attempt runs IMMEDIATELY rather than arming the usual settle timer. This
|
|
113
|
+
* is the recovery path, and the failure it recovers from is a lost timer — a
|
|
114
|
+
* long-lived `--hot` dev server whose `setTimeout` callbacks stop firing leaves
|
|
115
|
+
* `scheduleQueueDelivery` scheduling into the void, so a nudge that merely
|
|
116
|
+
* re-armed another timer would fail exactly like the delivery it was meant to
|
|
117
|
+
* rescue. The 500ms settle exists to let omp's own turn-end bookkeeping finish;
|
|
118
|
+
* by the time a client nudges, that window has long passed.
|
|
119
|
+
*
|
|
120
|
+
* The response carries the CANONICAL queue, so a client polling this as a
|
|
121
|
+
* safety net gets its panel reconciled in the same round trip.
|
|
111
122
|
*/
|
|
112
123
|
export async function nudgeQueueDelivery({ request, params }: ActionFunctionArgs) {
|
|
113
124
|
const sessionId = requireParam(params, 'sessionId');
|
|
@@ -115,7 +126,9 @@ export async function nudgeQueueDelivery({ request, params }: ActionFunctionArgs
|
|
|
115
126
|
if (request.method !== 'POST') return json({ error: 'Method not allowed' }, { status: 405 });
|
|
116
127
|
|
|
117
128
|
const session = getRpcSession(sessionId);
|
|
118
|
-
if (!session?.isAlive())
|
|
119
|
-
|
|
120
|
-
|
|
129
|
+
if (!session?.isAlive()) {
|
|
130
|
+
return json({ success: true, delivered: false, reason: 'session-not-live', queue: await listQueue(sessionId) });
|
|
131
|
+
}
|
|
132
|
+
const delivered = await deliverQueueNow(session);
|
|
133
|
+
return json({ success: true, delivered, queue: await listQueue(sessionId) });
|
|
121
134
|
}
|
|
@@ -4,6 +4,7 @@ import * as settingsAgents from '@/server/routes/settings/agents';
|
|
|
4
4
|
import * as settingsBehavior from '@/server/routes/settings/behavior';
|
|
5
5
|
import * as settingsCommands from '@/server/routes/settings/commands';
|
|
6
6
|
import * as settingsMcp from '@/server/routes/settings/mcp';
|
|
7
|
+
import * as settingsModelOverride from '@/server/routes/settings/model-override';
|
|
7
8
|
import * as settingsMcpTest from '@/server/routes/settings/mcp-test';
|
|
8
9
|
import * as settingsOmpConfig from '@/server/routes/settings/omp-config';
|
|
9
10
|
import * as settingsProjects from '@/server/routes/settings/projects';
|
|
@@ -18,6 +19,7 @@ export const settingsBindings: HandlerBinding[] = [
|
|
|
18
19
|
...bindingsFor(settingsBehavior, '/api/settings/behavior'),
|
|
19
20
|
...bindingsFor(settingsCommands, '/api/settings/commands'),
|
|
20
21
|
...bindingsFor(settingsMcp, '/api/settings/mcp'),
|
|
22
|
+
...bindingsFor(settingsModelOverride, '/api/settings/model-override'),
|
|
21
23
|
...bindingsFor(settingsMcpTest, '/api/settings/mcp-test'),
|
|
22
24
|
...bindingsFor(settingsOmpConfig, '/api/settings/omp-config'),
|
|
23
25
|
...bindingsFor(settingsProjects, '/api/settings/projects'),
|