ompchamber 0.3.0 → 0.4.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 +9 -6
- package/dist/client/index.html +1 -1
- package/dist/client/static/css/index.5291f58223.css +2 -0
- package/dist/client/static/js/async/{1k.544adbf77d.js → 1k.5974df3b87.js} +2 -2
- package/dist/client/static/js/async/3j.357c80ff38.js +2 -0
- package/dist/client/static/js/async/{3y.22075aa079.js → 3y.ededeebde8.js} +5 -5
- package/dist/client/static/js/async/51.95bcae98af.js +2 -0
- package/dist/client/static/js/async/6.f034c9b528.js +1 -0
- package/dist/client/static/js/async/a.e05da304c8.js +10 -0
- package/dist/client/static/js/async/ar.1e14da5588.js +1 -0
- package/dist/client/static/js/async/art.62139ed479.js +2 -0
- package/dist/client/static/js/async/ct.b74928f7a3.js +1 -0
- package/dist/client/static/js/async/fw.6a51998a8e.js +6 -0
- package/dist/client/static/js/async/g.7a4bbd4798.js +2 -0
- package/dist/client/static/js/async/g.7a4bbd4798.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{k.e878dd350f.js → k.7466330d1a.js} +2 -2
- package/dist/client/static/js/async/katex-vendor.de6a87c308.js +1 -0
- package/dist/client/static/js/async/kc.bbdb35b771.js +1 -0
- package/dist/client/static/js/async/l6.c56640c205.js +1 -0
- package/dist/client/static/js/async/{m.3fc017a055.js → m.4b29d34f93.js} +3 -3
- package/dist/client/static/js/async/{og.ae7acf03ac.js → og.634fba99ac.js} +2 -2
- package/dist/client/static/js/async/q.7fb4d2f374.js +21 -0
- package/dist/client/static/js/async/rd.cb6bf495bb.js +2 -0
- package/dist/client/static/js/async/rf.d626afc367.js +2 -0
- package/dist/client/static/js/async/s.413c2a4683.js +2 -0
- package/dist/client/static/js/async/s.413c2a4683.js.LICENSE.txt +6 -0
- package/dist/client/static/js/async/sy.d16be843a7.js +94 -0
- package/dist/client/static/js/async/t.cb3fcf8615.js +1 -0
- package/dist/client/static/js/async/ud.3065bfee31.js +2 -0
- package/dist/client/static/js/async/uf.46ad6209fa.js +2 -0
- package/dist/client/static/js/async/uf.46ad6209fa.js.LICENSE.txt +6 -0
- package/dist/client/static/js/async/v.7d28d3c241.js +34 -0
- package/dist/client/static/js/async/v0.394ed37c6b.js +2 -0
- package/dist/client/static/js/async/v0.394ed37c6b.js.LICENSE.txt +6 -0
- package/dist/client/static/js/async/vt.42416b707d.js +1 -0
- package/dist/client/static/js/async/vy.bbe114c4e0.js +2 -0
- package/dist/client/static/js/async/vy.bbe114c4e0.js.LICENSE.txt +6 -0
- package/dist/client/static/js/async/xw.ae67ba06fd.js +31 -0
- package/dist/client/static/js/async/xw.ae67ba06fd.js.LICENSE.txt +4 -0
- package/dist/client/static/js/async/yg.35bebd6d0c.js +149 -0
- package/dist/client/static/js/async/yv.2fa22f009a.js +1 -0
- package/dist/client/static/js/index.bf1b1fe988.js +33 -0
- package/dist/client/static/js/index.bf1b1fe988.js.LICENSE.txt +4 -0
- package/dist/client/static/js/{markdown-vendor.9da142c67b.js → markdown-vendor.71e43278b3.js} +1 -1
- package/dist/client/static/js/uy.5f791144ce.js +139 -0
- package/package.json +2 -1
- package/src/client/App.tsx +22 -5
- package/src/client/components/common/MarkdownRenderer.tsx +36 -0
- package/src/client/components/layout/desktop-layout/index.tsx +1 -1
- package/src/client/components/layout/session-sidebar/Modals.tsx +1 -1
- package/src/client/components/mobile/LayoutWrapper.tsx +1 -1
- package/src/client/components/settings/LazyModal.tsx +46 -0
- package/src/client/components/settings/Modal.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +43 -29
- package/src/client/components/workspace/chat-timeline/MessageItem.tsx +15 -69
- package/src/client/components/workspace/chat-timeline/MessageList.tsx +47 -66
- package/src/client/components/workspace/chat-timeline/RunFooter.tsx +60 -0
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +37 -21
- package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +22 -4
- package/src/client/components/workspace/chat-timeline/index.tsx +3 -6
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +15 -19
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/EnvelopeHeader.tsx +46 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/FallbackOutput.tsx +18 -68
- package/src/client/components/workspace/file-explorer/TreeItem.tsx +8 -2
- package/src/client/hooks/chat/omp/index.ts +9 -10
- package/src/client/hooks/chat/omp/revalidation-throttle.ts +6 -5
- package/src/client/hooks/chat/timeline/actions.ts +1 -2
- package/src/client/hooks/chat/timeline/index.ts +5 -0
- package/src/client/hooks/chat/timeline/send.ts +1 -2
- package/src/client/hooks/chat/timeline/session-load.ts +3 -4
- package/src/client/hooks/ui/syntax-ready.ts +16 -3
- package/src/client/main.tsx +2 -5
- package/src/client/stubs/elkjs.ts +37 -0
- package/src/client/tailwind.css +10 -0
- package/src/server/lib/fs/git-ignore.ts +49 -0
- package/src/server/lib/omp/rpc/constants.ts +10 -0
- package/src/server/lib/omp/rpc/lifecycle.test.ts +138 -0
- package/src/server/lib/omp/rpc/manager.ts +32 -4
- package/src/server/lib/omp/rpc/session-commands.ts +32 -9
- package/src/server/lib/omp/rpc/session-registry.ts +3 -2
- package/src/server/lib/omp/rpc/subagent-liveness.test.ts +62 -0
- package/src/server/lib/omp/rpc/subagent-liveness.ts +100 -0
- package/src/server/lib/omp/session/messages.test.ts +112 -0
- package/src/server/lib/omp/session/messages.ts +6 -5
- package/src/server/routes/agent/command.ts +34 -16
- package/src/server/routes/fs/read.ts +15 -7
- package/src/shared/lib/chat/notice-row.test.ts +89 -0
- package/src/shared/lib/chat/notice-row.ts +78 -0
- package/src/shared/lib/chat/omp/abort.ts +41 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +154 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +36 -7
- package/src/shared/lib/chat/timeline/run-footer.test.ts +84 -0
- package/src/shared/lib/chat/timeline/run-footer.ts +96 -0
- package/src/shared/lib/chat/tool-output.test.ts +91 -0
- package/src/shared/lib/chat/tool-output.ts +54 -0
- package/src/shared/lib/chat/xml-envelope.ts +158 -0
- package/src/shared/lib/code/highlighter-lazy.ts +106 -0
- package/src/shared/lib/code/highlighter.ts +31 -52
- package/src/shared/lib/code/syntax-highlight.test.ts +36 -1
- package/src/shared/lib/code/syntax-highlight.ts +17 -3
- package/src/shared/lib/format/time.ts +19 -0
- package/src/shared/lib/markdown/fira-code-fonts.css +51 -0
- package/src/shared/lib/markdown/katex-fonts.css +20 -0
- package/src/shared/lib/markdown/katex.ts +101 -0
- package/src/shared/lib/markdown/marked.ts +23 -14
- package/src/shared/lib/markdown/math.test.ts +109 -0
- package/src/shared/lib/markdown/mermaid.ts +6 -0
- package/src/shared/lib/markdown/sanitize.ts +48 -2
- package/src/shared/lib/omp/session/mapper.ts +11 -3
- package/src/shared/lib/omp/session/messages-map.ts +12 -12
- package/src/shared/types/chat.ts +3 -1
- package/src/shared/types/fs.ts +3 -0
- package/dist/client/static/css/g.f36aa429fa.css +0 -1
- package/dist/client/static/css/index.a0f07455bd.css +0 -2
- package/dist/client/static/css/katex-vendor.ca3a0600e8.css +0 -1
- package/dist/client/static/font/KaTeX_AMS-Regular.1608a09b4a.woff +0 -0
- package/dist/client/static/font/KaTeX_AMS-Regular.4aafdb685c.ttf +0 -0
- package/dist/client/static/font/KaTeX_Caligraphic-Bold.b67709187b.woff +0 -0
- package/dist/client/static/font/KaTeX_Caligraphic-Bold.cce5b8ecea.ttf +0 -0
- package/dist/client/static/font/KaTeX_Caligraphic-Regular.07ef19e7b0.ttf +0 -0
- package/dist/client/static/font/KaTeX_Caligraphic-Regular.dad44a7fc6.woff +0 -0
- package/dist/client/static/font/KaTeX_Fraktur-Bold.9f256b8593.woff +0 -0
- package/dist/client/static/font/KaTeX_Fraktur-Bold.b18f59e1d1.ttf +0 -0
- package/dist/client/static/font/KaTeX_Fraktur-Regular.7c1871215e.woff +0 -0
- package/dist/client/static/font/KaTeX_Fraktur-Regular.ed38e79f57.ttf +0 -0
- package/dist/client/static/font/KaTeX_Main-Bold.b74a1a8b2c.ttf +0 -0
- package/dist/client/static/font/KaTeX_Main-Bold.d181c4650d.woff +0 -0
- package/dist/client/static/font/KaTeX_Main-BoldItalic.70d8b0a530.ttf +0 -0
- package/dist/client/static/font/KaTeX_Main-BoldItalic.e3f82f9d27.woff +0 -0
- package/dist/client/static/font/KaTeX_Main-Italic.47373d1e51.ttf +0 -0
- package/dist/client/static/font/KaTeX_Main-Italic.9024d815ba.woff +0 -0
- package/dist/client/static/font/KaTeX_Main-Regular.7f51fe0340.woff +0 -0
- package/dist/client/static/font/KaTeX_Main-Regular.b7f8fe9b5f.ttf +0 -0
- package/dist/client/static/font/KaTeX_Math-BoldItalic.a879cf8383.ttf +0 -0
- package/dist/client/static/font/KaTeX_Math-BoldItalic.f1035d8d5d.woff +0 -0
- package/dist/client/static/font/KaTeX_Math-Italic.5295ba483a.woff +0 -0
- package/dist/client/static/font/KaTeX_Math-Italic.939bc64440.ttf +0 -0
- package/dist/client/static/font/KaTeX_SansSerif-Bold.94e1e8dc5f.ttf +0 -0
- package/dist/client/static/font/KaTeX_SansSerif-Bold.bf59d231c3.woff +0 -0
- package/dist/client/static/font/KaTeX_SansSerif-Italic.7c9bc82b17.woff +0 -0
- package/dist/client/static/font/KaTeX_SansSerif-Italic.b4c20c84d8.ttf +0 -0
- package/dist/client/static/font/KaTeX_SansSerif-Regular.740484788f.woff +0 -0
- package/dist/client/static/font/KaTeX_SansSerif-Regular.d4d7ba4804.ttf +0 -0
- package/dist/client/static/font/KaTeX_Script-Regular.0750571032.woff +0 -0
- package/dist/client/static/font/KaTeX_Script-Regular.fe9cbbe1a0.ttf +0 -0
- package/dist/client/static/font/KaTeX_Size1-Regular.e1e279cbdd.woff +0 -0
- package/dist/client/static/font/KaTeX_Size1-Regular.fabc004aab.ttf +0 -0
- package/dist/client/static/font/KaTeX_Size2-Regular.577270225e.woff +0 -0
- package/dist/client/static/font/KaTeX_Size2-Regular.d6b476ecd3.ttf +0 -0
- package/dist/client/static/font/KaTeX_Size3-Regular.9acaf01c64.woff +0 -0
- package/dist/client/static/font/KaTeX_Size3-Regular.a144ef5840.ttf +0 -0
- package/dist/client/static/font/KaTeX_Size4-Regular.7a996c9da2.woff +0 -0
- package/dist/client/static/font/KaTeX_Size4-Regular.fbccdabe0a.ttf +0 -0
- package/dist/client/static/font/KaTeX_Typewriter-Regular.6258592bdc.woff +0 -0
- package/dist/client/static/font/KaTeX_Typewriter-Regular.d97aaf4a1e.ttf +0 -0
- package/dist/client/static/font/fira-code-cyrillic-400-normal.5971438a4a.woff2 +0 -0
- package/dist/client/static/font/fira-code-cyrillic-400-normal.9bb7bfea9c.woff +0 -0
- package/dist/client/static/font/fira-code-cyrillic-500-normal.5486265b18.woff2 +0 -0
- package/dist/client/static/font/fira-code-cyrillic-500-normal.89f92bfb31.woff +0 -0
- package/dist/client/static/font/fira-code-cyrillic-600-normal.2805b497e5.woff +0 -0
- package/dist/client/static/font/fira-code-cyrillic-600-normal.86e0df22a3.woff2 +0 -0
- package/dist/client/static/font/fira-code-cyrillic-700-normal.505a88f22a.woff +0 -0
- package/dist/client/static/font/fira-code-cyrillic-700-normal.76f5e8d2e2.woff2 +0 -0
- package/dist/client/static/font/fira-code-cyrillic-ext-400-normal.3c4b15e57d.woff2 +0 -0
- package/dist/client/static/font/fira-code-cyrillic-ext-400-normal.61323debaa.woff +0 -0
- package/dist/client/static/font/fira-code-cyrillic-ext-500-normal.0f34210f2a.woff2 +0 -0
- package/dist/client/static/font/fira-code-cyrillic-ext-500-normal.2497cfa1aa.woff +0 -0
- package/dist/client/static/font/fira-code-cyrillic-ext-600-normal.40fce94af9.woff2 +0 -0
- package/dist/client/static/font/fira-code-cyrillic-ext-600-normal.6c8774b829.woff +0 -0
- package/dist/client/static/font/fira-code-cyrillic-ext-700-normal.4b742eb865.woff +0 -0
- package/dist/client/static/font/fira-code-cyrillic-ext-700-normal.e75eb4fccc.woff2 +0 -0
- package/dist/client/static/font/fira-code-greek-400-normal.bb900468b6.woff2 +0 -0
- package/dist/client/static/font/fira-code-greek-400-normal.be76e699ba.woff +0 -0
- package/dist/client/static/font/fira-code-greek-500-normal.176c9fb001.woff2 +0 -0
- package/dist/client/static/font/fira-code-greek-500-normal.d2c596136b.woff +0 -0
- package/dist/client/static/font/fira-code-greek-600-normal.490c3deed7.woff +0 -0
- package/dist/client/static/font/fira-code-greek-600-normal.7df14376d9.woff2 +0 -0
- package/dist/client/static/font/fira-code-greek-700-normal.c82b6072fc.woff2 +0 -0
- package/dist/client/static/font/fira-code-greek-700-normal.d5f2b2917b.woff +0 -0
- package/dist/client/static/font/fira-code-greek-ext-400-normal.4849a79781.woff +0 -0
- package/dist/client/static/font/fira-code-greek-ext-400-normal.7f5bfc4538.woff2 +0 -0
- package/dist/client/static/font/fira-code-greek-ext-500-normal.aaccca4075.woff2 +0 -0
- package/dist/client/static/font/fira-code-greek-ext-500-normal.e383181256.woff +0 -0
- package/dist/client/static/font/fira-code-greek-ext-600-normal.78cb4bff8e.woff +0 -0
- package/dist/client/static/font/fira-code-greek-ext-600-normal.d6c93d54f9.woff2 +0 -0
- package/dist/client/static/font/fira-code-greek-ext-700-normal.57ec7c92d9.woff +0 -0
- package/dist/client/static/font/fira-code-greek-ext-700-normal.8fafdae5e8.woff2 +0 -0
- package/dist/client/static/font/fira-code-latin-400-normal.973295532b.woff +0 -0
- package/dist/client/static/font/fira-code-latin-500-normal.7ae2be8fb8.woff +0 -0
- package/dist/client/static/font/fira-code-latin-600-normal.1dbbe5bc27.woff +0 -0
- package/dist/client/static/font/fira-code-latin-700-normal.497a70148b.woff +0 -0
- package/dist/client/static/font/fira-code-latin-ext-400-normal.9dfae29aaf.woff +0 -0
- package/dist/client/static/font/fira-code-latin-ext-400-normal.e534ae46e2.woff2 +0 -0
- package/dist/client/static/font/fira-code-latin-ext-500-normal.24c7b781b0.woff +0 -0
- package/dist/client/static/font/fira-code-latin-ext-500-normal.aad44ab072.woff2 +0 -0
- package/dist/client/static/font/fira-code-latin-ext-600-normal.7bed0f2f7e.woff +0 -0
- package/dist/client/static/font/fira-code-latin-ext-600-normal.c509a7c9f1.woff2 +0 -0
- package/dist/client/static/font/fira-code-latin-ext-700-normal.6c66c8b0a9.woff2 +0 -0
- package/dist/client/static/font/fira-code-latin-ext-700-normal.88f64e2822.woff +0 -0
- package/dist/client/static/font/fira-code-symbols2-400-normal.97d3f4b17c.woff +0 -0
- package/dist/client/static/font/fira-code-symbols2-500-normal.b06f1a06e4.woff +0 -0
- package/dist/client/static/font/fira-code-symbols2-600-normal.304b653b93.woff +0 -0
- package/dist/client/static/font/fira-code-symbols2-700-normal.d8ce0ad586.woff +0 -0
- package/dist/client/static/js/async/3j.a2fcea99be.js +0 -2
- package/dist/client/static/js/async/51.1938cec320.js +0 -2
- package/dist/client/static/js/async/6.7929428505.js +0 -1
- package/dist/client/static/js/async/69.a18a10882f.js +0 -1
- package/dist/client/static/js/async/a.bce4542392.js +0 -1
- package/dist/client/static/js/async/ar.9dc8b1992a.js +0 -2
- package/dist/client/static/js/async/bi.cbfe8eaa8e.js +0 -1
- package/dist/client/static/js/async/ct.38d9da8a5a.js +0 -1
- package/dist/client/static/js/async/fw.fb1a08b92d.js +0 -6
- package/dist/client/static/js/async/l6.45f3b62e15.js +0 -1
- package/dist/client/static/js/async/q.0b6fb9bf8d.js +0 -21
- package/dist/client/static/js/async/rd.b77011ab7f.js +0 -2
- package/dist/client/static/js/async/rf.cfbde7f744.js +0 -2
- package/dist/client/static/js/async/s.c7d521a8ae.js +0 -2
- package/dist/client/static/js/async/sy.7345abcd67.js +0 -94
- package/dist/client/static/js/async/t.0dcb01dfde.js +0 -1
- package/dist/client/static/js/async/v.5c7a4b087b.js +0 -1
- package/dist/client/static/js/async/vu.311f1d202c.js +0 -34
- package/dist/client/static/js/async/yg.9bd4e09ecb.js +0 -149
- package/dist/client/static/js/async/yv.593a3f7d54.js +0 -1
- package/dist/client/static/js/g.124fba0826.js +0 -139
- package/dist/client/static/js/index.907fd4670e.js +0 -68
- package/dist/client/static/js/katex-vendor.95e48cf65a.js +0 -1
- package/src/shared/lib/chat/order.ts +0 -40
- /package/dist/client/static/js/async/{1k.544adbf77d.js.LICENSE.txt → 1k.5974df3b87.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{3j.a2fcea99be.js.LICENSE.txt → 3j.357c80ff38.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{3y.22075aa079.js.LICENSE.txt → 3y.ededeebde8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{51.1938cec320.js.LICENSE.txt → 51.95bcae98af.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{ar.9dc8b1992a.js.LICENSE.txt → a.e05da304c8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{fw.fb1a08b92d.js.LICENSE.txt → art.62139ed479.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.3fc017a055.js.LICENSE.txt → fw.6a51998a8e.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.0b6fb9bf8d.js.LICENSE.txt → m.4b29d34f93.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{rf.cfbde7f744.js.LICENSE.txt → q.7fb4d2f374.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{rd.b77011ab7f.js.LICENSE.txt → rd.cb6bf495bb.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{s.c7d521a8ae.js.LICENSE.txt → rf.d626afc367.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.907fd4670e.js.LICENSE.txt → async/ud.3065bfee31.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{g.124fba0826.js.LICENSE.txt → uy.5f791144ce.js.LICENSE.txt} +0 -0
|
@@ -21,7 +21,6 @@ import type { Dispatch, RefObject, SetStateAction } from 'preact/compat';
|
|
|
21
21
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
|
|
22
22
|
|
|
23
23
|
import type { ChatMessageData } from '@/shared/types';
|
|
24
|
-
import { normalizeNoticePositions } from '@/shared/lib/chat/order';
|
|
25
24
|
import { SESSION_META_RETRY_SCHEDULE_MS } from '@/shared/lib/workspace/refresh-cadence';
|
|
26
25
|
|
|
27
26
|
export interface SessionDataShape {
|
|
@@ -210,9 +209,9 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
|
|
|
210
209
|
if (isGeneratingRef.current) {
|
|
211
210
|
const fetchedIds = fetched.map((m: ChatMessageData) => m.id);
|
|
212
211
|
const liveTail = localMessagesRef.current.filter(m => !fetchedIds.includes(m.id));
|
|
213
|
-
setLocalMessages(
|
|
212
|
+
setLocalMessages([...fetched, ...liveTail]);
|
|
214
213
|
} else {
|
|
215
|
-
setLocalMessages(
|
|
214
|
+
setLocalMessages(fetched);
|
|
216
215
|
}
|
|
217
216
|
} else if (!sessionId.startsWith('new-') && !optimisticOwnsTail) {
|
|
218
217
|
setLocalMessages([]);
|
|
@@ -284,7 +283,7 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
|
|
|
284
283
|
setLocalMessages(prev => {
|
|
285
284
|
// Drop overlap guard: the server window is index-based, so the
|
|
286
285
|
// fetched slice is strictly older than everything already mounted.
|
|
287
|
-
return [...
|
|
286
|
+
return [...older, ...prev];
|
|
288
287
|
});
|
|
289
288
|
// Bump the prepend tick so the layout effect below re-runs for THIS
|
|
290
289
|
// commit — it previously depended only on stable identities and ran
|
|
@@ -5,13 +5,26 @@
|
|
|
5
5
|
|
|
6
6
|
import { useEffect, useState } from 'preact/hooks';
|
|
7
7
|
|
|
8
|
-
import { isSyntaxReady, onSyntaxReady } from '@/shared/lib/code/highlighter';
|
|
8
|
+
import { isSyntaxReady, onLanguageReady, onSyntaxReady } from '@/shared/lib/code/highlighter';
|
|
9
9
|
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Re-renders when the Shiki highlighter finishes booting **and** whenever a
|
|
12
|
+
* grammar lands. Grammars are fetched on demand — a language nobody renders is
|
|
13
|
+
* never downloaded — so a component may see a `true` value here while the
|
|
14
|
+
* grammar for its own code is still in flight; the second subscription is what
|
|
15
|
+
* swaps the plain-text fallback for tokenized markup once the chunk arrives.
|
|
16
|
+
*/
|
|
11
17
|
export function useSyntaxReady(): boolean {
|
|
12
18
|
const [ready, setReady] = useState<boolean>(() => isSyntaxReady());
|
|
13
19
|
|
|
14
|
-
useEffect(() =>
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const offBoot = onSyntaxReady(() => setReady(true));
|
|
22
|
+
const offLang = onLanguageReady(() => setReady(true));
|
|
23
|
+
return () => {
|
|
24
|
+
offBoot();
|
|
25
|
+
offLang();
|
|
26
|
+
};
|
|
27
|
+
}, []);
|
|
15
28
|
|
|
16
29
|
return ready;
|
|
17
30
|
}
|
package/src/client/main.tsx
CHANGED
|
@@ -4,11 +4,8 @@ import { bootSyntax } from '@/shared/lib/code/highlighter';
|
|
|
4
4
|
import { primeChamberSettings } from '@/shared/lib/settings/client';
|
|
5
5
|
|
|
6
6
|
import '@/client/tailwind.css';
|
|
7
|
-
import '
|
|
8
|
-
import '
|
|
9
|
-
import '@fontsource/fira-code/500.css';
|
|
10
|
-
import '@fontsource/fira-code/600.css';
|
|
11
|
-
import '@fontsource/fira-code/700.css';
|
|
7
|
+
import '@/shared/lib/markdown/katex-fonts.css';
|
|
8
|
+
import '@/shared/lib/markdown/fira-code-fonts.css';
|
|
12
9
|
|
|
13
10
|
const root = document.getElementById('app');
|
|
14
11
|
const bootstrap = window.__OMP_BOOTSTRAP__;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Browser replacement for `elkjs`, aliased in `rsbuild.config.ts`.
|
|
8
|
+
*
|
|
9
|
+
* Mermaid 12 changed the global `layout` default from `dagre` to `elk`, so every
|
|
10
|
+
* flowchart pulled in `elkjs/lib/elk.bundled.js` whether the diagram asked for
|
|
11
|
+
* ELK or not — the single largest artifact in the client build (~1.4 MB raw,
|
|
12
|
+
* 434 kB gzip). `src/shared/lib/markdown/mermaid.ts` pins `layout: 'dagre'`,
|
|
13
|
+
* which is what makes this stub safe: with dagre selected explicitly, mermaid
|
|
14
|
+
* never calls into `runElkLayoutCore`, the only code path that constructs an ELK
|
|
15
|
+
* instance (verified across 20 diagram types, byte-identical results to a build
|
|
16
|
+
* with the real elkjs).
|
|
17
|
+
*
|
|
18
|
+
* `layout: 'elk'` is still a valid *request* — a diagram using the
|
|
19
|
+
* `flowchart-elk` directive, or a syntax flagged `@config{layout: "elk"}`. Those
|
|
20
|
+
* now fail loudly here instead of silently rendering with a different layout
|
|
21
|
+
* than asked for. Throwing is deliberate: mermaid's `render()` propagates the
|
|
22
|
+
* error to the caller, and `mermaid.ts` already renders a fallback block on
|
|
23
|
+
* failure, so a mis-specified diagram degrades to a visible message rather than
|
|
24
|
+
* a wrong diagram.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
function unavailable(): never {
|
|
28
|
+
throw new Error('elkjs is not bundled in this build; use the default dagre layout');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class ELK {}
|
|
32
|
+
|
|
33
|
+
export default new Proxy(ELK as unknown as object, {
|
|
34
|
+
construct: unavailable,
|
|
35
|
+
apply: unavailable,
|
|
36
|
+
get: unavailable,
|
|
37
|
+
}) as unknown as typeof ELK;
|
package/src/client/tailwind.css
CHANGED
|
@@ -285,6 +285,16 @@
|
|
|
285
285
|
[data-theme="one-dark-pro-soft"] .shiki span { color: var(--shiki-dark); }
|
|
286
286
|
.prose-content .katex-display { overflow-x: auto; overflow-y: hidden; }
|
|
287
287
|
|
|
288
|
+
/* Math placeholder: `marked.ts` emits a `.math-pending` span and KaTeX is
|
|
289
|
+
* fetched on demand (~522 kB async chunk), so the span is empty for one frame.
|
|
290
|
+
* Reserve display-math height so the message does not jump when the formula
|
|
291
|
+
* lands, and keep invalid/queued source faintly visible rather than blank. */
|
|
292
|
+
.prose-content .math-pending::before {
|
|
293
|
+
content: '…';
|
|
294
|
+
opacity: 0.35;
|
|
295
|
+
}
|
|
296
|
+
.prose-content p:has(> .math-pending[data-math-display='1']) { min-height: 2.5rem; }
|
|
297
|
+
|
|
288
298
|
/* Streaming placeholder: remend turns a half-received [text](url into this
|
|
289
299
|
* span; it becomes a real link once the URL arrives in a later frame. */
|
|
290
300
|
.prose-content .md-incomplete-link {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Git-ignore resolution for the Files panel.
|
|
8
|
+
*
|
|
9
|
+
* `git check-ignore` is the only authority that evaluates every rule source in
|
|
10
|
+
* one pass — per-directory `.gitignore`, `.git/info/exclude`, and the global
|
|
11
|
+
* `core.excludesFile` (`~/.gitignore`) — so the panel dims exactly what git
|
|
12
|
+
* would refuse to track. Paths are fed NUL-separated over stdin (`--stdin -z`)
|
|
13
|
+
* so a directory listing of any size cannot overflow argv, and the verbatim
|
|
14
|
+
* NUL-separated reply maps straight back onto those same paths.
|
|
15
|
+
*
|
|
16
|
+
* Paths must be relative to `dir` and must exist on disk: git resolves
|
|
17
|
+
* directory-only patterns (`build/`) by stat-ing the path.
|
|
18
|
+
*/
|
|
19
|
+
export async function collectIgnoredPaths(dir: string, relPaths: string[]): Promise<Set<string>> {
|
|
20
|
+
if (relPaths.length === 0) return new Set();
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const proc = Bun.spawn({
|
|
24
|
+
cmd: ['git', 'check-ignore', '--stdin', '-z'],
|
|
25
|
+
cwd: dir,
|
|
26
|
+
stdin: 'pipe',
|
|
27
|
+
stdout: 'pipe',
|
|
28
|
+
stderr: 'ignore',
|
|
29
|
+
timeout: 10_000,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Drain stdout while the listing is written: a directory big enough to
|
|
33
|
+
// fill both pipe buffers would otherwise deadlock child against parent.
|
|
34
|
+
const stdout = new Response(proc.stdout).text();
|
|
35
|
+
proc.stdin.write(relPaths.map(rel => `${rel}\0`).join(''));
|
|
36
|
+
// FileSink.end() types as `number | Promise<number>` — EPIPE (child gone)
|
|
37
|
+
// must not surface as an unhandled rejection.
|
|
38
|
+
void Promise.resolve(proc.stdin.end()).catch(() => {});
|
|
39
|
+
|
|
40
|
+
const [text, exitCode] = await Promise.all([stdout, proc.exited]);
|
|
41
|
+
// 0 = at least one path matched, 1 = none, 128 = no repository. Only 0 can
|
|
42
|
+
// carry a match list.
|
|
43
|
+
if (exitCode !== 0) return new Set();
|
|
44
|
+
|
|
45
|
+
return new Set(text.split('\0').filter(Boolean));
|
|
46
|
+
} catch {
|
|
47
|
+
return new Set(); // git missing / spawn failed — nothing is dimmed
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -70,12 +70,22 @@ export interface WebSessionState {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
export const IDLE_DESTROY_MS = 10 * 60 * 1000;
|
|
73
|
+
// Longest a subagent may go without a frame before the wrapper stops counting
|
|
74
|
+
// it as live work. A terminal frame lost to a protocol hiccup would otherwise
|
|
75
|
+
// pin the session's omp process (and its whole process group) open forever; the
|
|
76
|
+
// price of the window is at most one idle omp process.
|
|
77
|
+
export const SUBAGENT_STALE_MS = 30 * 60 * 1000;
|
|
73
78
|
export const READY_TIMEOUT_MS = 120_000;
|
|
74
79
|
export const GET_STATE_TIMEOUT_MS = 5_000;
|
|
75
80
|
export const PROMPT_ACK_TIMEOUT_MS = 30_000;
|
|
76
81
|
export const NON_TERMINAL_CONTINUATION_GRACE_MS = 2_000;
|
|
77
82
|
export const AWAITING_AGENT_START_TIMEOUT_MS = 10_000;
|
|
78
83
|
export const RESTARTING_MESSAGE = 'This session is restarting — retry in a moment.';
|
|
84
|
+
// A command timeout is not proof an omp child is wedged: omp runs RPC handlers
|
|
85
|
+
// one at a time, so a slow `get_state`/`prompt` ack can simply be queued behind
|
|
86
|
+
// the running turn. That session keeps its process — only a session that is
|
|
87
|
+
// idle AND unresponsive is reset.
|
|
88
|
+
export const SESSION_BUSY_MESSAGE = 'The OMP session is busy with the running turn; retry once it settles.';
|
|
79
89
|
|
|
80
90
|
export class WebRpcError extends Error {
|
|
81
91
|
readonly code: string;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The wrapper's lifecycle rules: which conditions may reclaim a session's omp
|
|
8
|
+
* process. A reset kills the whole process group, so a wrong answer here
|
|
9
|
+
* destroys a live turn and every subagent under it.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'bun:test';
|
|
13
|
+
|
|
14
|
+
import { AgentSessionWrapper, WebRpcError } from '@/server/lib/omp/rpc/manager';
|
|
15
|
+
import { RpcCommandTimeoutError, type RpcProcess } from '@/server/lib/omp/rpc/process';
|
|
16
|
+
|
|
17
|
+
/** Commands whose ack is fabricated as timed out; everything else resolves, so
|
|
18
|
+
* only the timeout policy is under test. */
|
|
19
|
+
const TIMED_OUT_COMMANDS: Record<string, true> = { get_state: true, prompt: true };
|
|
20
|
+
|
|
21
|
+
interface StubProcess {
|
|
22
|
+
proc: RpcProcess;
|
|
23
|
+
emit(frame: Record<string, unknown>): void;
|
|
24
|
+
disposeCount(): number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeProc(): StubProcess {
|
|
28
|
+
let listener: ((frame: unknown) => void) | null = null;
|
|
29
|
+
let disposals = 0;
|
|
30
|
+
const proc = {
|
|
31
|
+
isAlive: true,
|
|
32
|
+
pid: 4242,
|
|
33
|
+
onFrame(next: (frame: unknown) => void) {
|
|
34
|
+
listener = next;
|
|
35
|
+
return () => {
|
|
36
|
+
listener = null;
|
|
37
|
+
};
|
|
38
|
+
},
|
|
39
|
+
sendCommand(command: { type: string }) {
|
|
40
|
+
if (TIMED_OUT_COMMANDS[command.type] === true) {
|
|
41
|
+
return Promise.reject(new RpcCommandTimeoutError(command.type, 1));
|
|
42
|
+
}
|
|
43
|
+
return Promise.resolve(undefined);
|
|
44
|
+
},
|
|
45
|
+
sendFrame() {},
|
|
46
|
+
async dispose() {
|
|
47
|
+
disposals += 1;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
proc: proc as unknown as RpcProcess,
|
|
52
|
+
emit: (frame) => listener?.(frame),
|
|
53
|
+
disposeCount: () => disposals,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function rejectionOf(operation: Promise<unknown>): Promise<WebRpcError> {
|
|
58
|
+
const error = await operation.then(() => null, (caught: unknown) => caught);
|
|
59
|
+
expect(error).toBeInstanceOf(WebRpcError);
|
|
60
|
+
return error as WebRpcError;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('AgentSessionWrapper lifecycle', () => {
|
|
64
|
+
// Every command arms the wrapper's idle timer; keep the clock fake so a test
|
|
65
|
+
// never schedules a real ten-minute window.
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
vi.useFakeTimers();
|
|
68
|
+
});
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
vi.useRealTimers();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('resets a session that ignores commands while idle', async () => {
|
|
74
|
+
const stub = makeProc();
|
|
75
|
+
const wrapper = new AgentSessionWrapper(stub.proc, '/tmp');
|
|
76
|
+
|
|
77
|
+
const error = await rejectionOf(wrapper.send({ type: 'get_state' }));
|
|
78
|
+
|
|
79
|
+
expect(error.code).toBe('session_unresponsive');
|
|
80
|
+
expect(stub.disposeCount()).toBe(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('keeps the process when a get_state times out mid-turn', async () => {
|
|
84
|
+
const stub = makeProc();
|
|
85
|
+
const wrapper = new AgentSessionWrapper(stub.proc, '/tmp');
|
|
86
|
+
wrapper.start();
|
|
87
|
+
stub.emit({ type: 'agent_start' });
|
|
88
|
+
|
|
89
|
+
const error = await rejectionOf(wrapper.send({ type: 'get_state' }));
|
|
90
|
+
|
|
91
|
+
expect(error.code).toBe('session_busy');
|
|
92
|
+
expect(stub.disposeCount()).toBe(0);
|
|
93
|
+
expect(wrapper.isRunning()).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('keeps the process when a prompt ack times out mid-turn', async () => {
|
|
97
|
+
const stub = makeProc();
|
|
98
|
+
const wrapper = new AgentSessionWrapper(stub.proc, '/tmp');
|
|
99
|
+
wrapper.start();
|
|
100
|
+
stub.emit({ type: 'agent_start' });
|
|
101
|
+
|
|
102
|
+
const error = await rejectionOf(wrapper.send({ type: 'prompt', message: 'hi' }));
|
|
103
|
+
|
|
104
|
+
expect(error.code).toBe('session_busy');
|
|
105
|
+
expect(stub.disposeCount()).toBe(0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('idle reclaim waits for subagents that outlive the turn', () => {
|
|
109
|
+
const stub = makeProc();
|
|
110
|
+
const wrapper = new AgentSessionWrapper(stub.proc, '/tmp', null, { idleDestroyMs: 20 });
|
|
111
|
+
wrapper.start();
|
|
112
|
+
|
|
113
|
+
stub.emit({ type: 'agent_start' });
|
|
114
|
+
stub.emit({ type: 'agent_end', isTerminal: true });
|
|
115
|
+
stub.emit({ type: 'subagent_lifecycle', payload: { id: 's1', status: 'started', index: 0 } });
|
|
116
|
+
expect(wrapper.isRunning()).toBe(false);
|
|
117
|
+
expect(wrapper.isBusy()).toBe(true);
|
|
118
|
+
|
|
119
|
+
vi.advanceTimersByTime(200);
|
|
120
|
+
expect(stub.disposeCount()).toBe(0);
|
|
121
|
+
|
|
122
|
+
stub.emit({ type: 'subagent_lifecycle', payload: { id: 's1', status: 'completed', index: 0 } });
|
|
123
|
+
expect(wrapper.isBusy()).toBe(false);
|
|
124
|
+
|
|
125
|
+
vi.advanceTimersByTime(200);
|
|
126
|
+
expect(stub.disposeCount()).toBe(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('force_reset reclaims a process whose turn will not stop', async () => {
|
|
130
|
+
const stub = makeProc();
|
|
131
|
+
const wrapper = new AgentSessionWrapper(stub.proc, '/tmp');
|
|
132
|
+
stub.emit({ type: 'agent_start' });
|
|
133
|
+
|
|
134
|
+
await wrapper.send({ type: 'force_reset' });
|
|
135
|
+
|
|
136
|
+
expect(stub.disposeCount()).toBe(1);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -20,8 +20,9 @@ import { PendingUiDialogs } from '@/server/lib/omp/rpc/pending-ui-dialogs';
|
|
|
20
20
|
import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
|
|
21
21
|
import { notifyRunningChange } from '@/server/lib/omp/rpc/session-registry';
|
|
22
22
|
import { dispatchSessionCommand } from '@/server/lib/omp/rpc/session-commands';
|
|
23
|
+
import { SubagentLiveness } from '@/server/lib/omp/rpc/subagent-liveness';
|
|
23
24
|
import { markStreamStatus } from '@/shared/lib/omp/session/stream-state.server';
|
|
24
|
-
import { GET_STATE_TIMEOUT_MS, IDLE_DESTROY_MS, NON_TERMINAL_CONTINUATION_GRACE_MS, READY_TIMEOUT_MS, type AgentEvent, type EventListener, type RpcSessionState } from '@/server/lib/omp/rpc/constants';
|
|
25
|
+
import { GET_STATE_TIMEOUT_MS, IDLE_DESTROY_MS, NON_TERMINAL_CONTINUATION_GRACE_MS, READY_TIMEOUT_MS, SUBAGENT_STALE_MS, type AgentEvent, type EventListener, type RpcSessionState } from '@/server/lib/omp/rpc/constants';
|
|
25
26
|
|
|
26
27
|
export type {
|
|
27
28
|
AgentEvent,
|
|
@@ -31,6 +32,13 @@ export type {
|
|
|
31
32
|
} from '@/server/lib/omp/rpc/constants';
|
|
32
33
|
export { WebRpcError, resolveSpawnCwd } from '@/server/lib/omp/rpc/constants';
|
|
33
34
|
|
|
35
|
+
/** Overrides for the wrapper's own timers. Production always uses the
|
|
36
|
+
* constant; tests shrink the window so the lifecycle rules stay fast. */
|
|
37
|
+
export interface AgentSessionWrapperOptions {
|
|
38
|
+
/** Idle window before an unused process is reclaimed. */
|
|
39
|
+
idleDestroyMs?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
34
42
|
export class AgentSessionWrapper {
|
|
35
43
|
private listeners: EventListener[] = [];
|
|
36
44
|
promptRunning = false;
|
|
@@ -55,14 +63,20 @@ export class AgentSessionWrapper {
|
|
|
55
63
|
// `extension_ui_request`, so a client that reloads mid-dialog can only learn
|
|
56
64
|
// the id it must answer from here.
|
|
57
65
|
private readonly pendingUiDialogs = new PendingUiDialogs();
|
|
66
|
+
// Live subagents, folded from the frames omp streams after the subscription
|
|
67
|
+
// set in initialize(). No other flag on this wrapper can see them, and an
|
|
68
|
+
// idle reclaim or a spawn-mode reconcile must never kill a running subagent.
|
|
69
|
+
private readonly subagents = new SubagentLiveness();
|
|
70
|
+
readonly idleDestroyMs: number;
|
|
58
71
|
proc: RpcProcess;
|
|
59
72
|
readonly cwd: string;
|
|
60
73
|
private readonly recordedCwd: string | null;
|
|
61
74
|
|
|
62
|
-
constructor(proc: RpcProcess, cwd: string, recordedCwd?: string | null) {
|
|
75
|
+
constructor(proc: RpcProcess, cwd: string, recordedCwd?: string | null, options: AgentSessionWrapperOptions = {}) {
|
|
63
76
|
this.proc = proc;
|
|
64
77
|
this.cwd = cwd;
|
|
65
78
|
this.recordedCwd = recordedCwd ?? null;
|
|
79
|
+
this.idleDestroyMs = options.idleDestroyMs ?? IDLE_DESTROY_MS;
|
|
66
80
|
}
|
|
67
81
|
|
|
68
82
|
get sessionId(): string {
|
|
@@ -87,6 +101,13 @@ export class AgentSessionWrapper {
|
|
|
87
101
|
return this.isAlive() && (this.promptRunning || this.streaming || this.compacting || this.bashRunning);
|
|
88
102
|
}
|
|
89
103
|
|
|
104
|
+
/** Anything in flight that a process reset would destroy: the current turn,
|
|
105
|
+
* a compaction, a shell command, or live subagents — which outlive the turn
|
|
106
|
+
* that spawned them, so no other flag here can see them. */
|
|
107
|
+
isBusy(): boolean {
|
|
108
|
+
return this.isRunning() || this.subagents.liveCount(Date.now(), SUBAGENT_STALE_MS) > 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
90
111
|
start(): void {
|
|
91
112
|
this.unsubscribeFrames = this.proc.onFrame((frame) => this.handleFrame(frame));
|
|
92
113
|
this.resetIdleTimer();
|
|
@@ -188,6 +209,13 @@ export class AgentSessionWrapper {
|
|
|
188
209
|
case 'extension_ui_request':
|
|
189
210
|
this.pendingUiDialogs.track(event);
|
|
190
211
|
break;
|
|
212
|
+
// Subagent frames carry no turn state, but they are the only proof that
|
|
213
|
+
// work is still running once the parent turn has ended.
|
|
214
|
+
case 'subagent_lifecycle':
|
|
215
|
+
case 'subagent_progress':
|
|
216
|
+
case 'subagent_event':
|
|
217
|
+
this.subagents.observe(event, Date.now());
|
|
218
|
+
break;
|
|
191
219
|
case 'response': {
|
|
192
220
|
if (event.success === false && event.command === 'prompt') {
|
|
193
221
|
this.promptRunning = false;
|
|
@@ -226,12 +254,12 @@ export class AgentSessionWrapper {
|
|
|
226
254
|
this.lastIdleReset = now;
|
|
227
255
|
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
228
256
|
this.idleTimer = setTimeout(() => {
|
|
229
|
-
if (this.
|
|
257
|
+
if (this.isBusy()) {
|
|
230
258
|
this.resetIdleTimer(true);
|
|
231
259
|
return;
|
|
232
260
|
}
|
|
233
261
|
this.destroy();
|
|
234
|
-
},
|
|
262
|
+
}, this.idleDestroyMs);
|
|
235
263
|
}
|
|
236
264
|
|
|
237
265
|
onEvent(listener: EventListener): () => void {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { RpcCommandTimeoutError, type RpcProcess } from '@/server/lib/omp/rpc/process';
|
|
13
|
-
import { AWAITING_AGENT_START_TIMEOUT_MS, GET_STATE_TIMEOUT_MS, IMAGE_BEARING_COMMANDS, PASSTHROUGH_COMMANDS, PROMPT_ACK_TIMEOUT_MS, RESTARTING_MESSAGE, WebRpcError, toImageContents, type AgentEvent, type RpcSessionState, validateAgentImages } from '@/server/lib/omp/rpc/constants';
|
|
13
|
+
import { AWAITING_AGENT_START_TIMEOUT_MS, GET_STATE_TIMEOUT_MS, IMAGE_BEARING_COMMANDS, PASSTHROUGH_COMMANDS, PROMPT_ACK_TIMEOUT_MS, RESTARTING_MESSAGE, SESSION_BUSY_MESSAGE, WebRpcError, toImageContents, type AgentEvent, type RpcSessionState, validateAgentImages } from '@/server/lib/omp/rpc/constants';
|
|
14
14
|
import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
|
|
15
15
|
import { notifyRunningChange } from '@/server/lib/omp/rpc/session-registry';
|
|
16
16
|
import { markStreamStatus } from '@/shared/lib/omp/session/stream-state.server';
|
|
@@ -21,6 +21,10 @@ export interface SessionCommandHost extends WebStateHost {
|
|
|
21
21
|
restarting: boolean;
|
|
22
22
|
proc: RpcProcess;
|
|
23
23
|
isAlive(): boolean;
|
|
24
|
+
/** Anything a reset would destroy: the running turn, a compaction, a shell
|
|
25
|
+
* command, or live subagents. A timed-out command against a busy session is
|
|
26
|
+
* queued behind that work, not evidence the child is wedged. */
|
|
27
|
+
isBusy(): boolean;
|
|
24
28
|
/** Real omp session id (empty before the first get_state). */
|
|
25
29
|
sessionId: string;
|
|
26
30
|
emit(event: AgentEvent): void;
|
|
@@ -31,6 +35,20 @@ export interface SessionCommandHost extends WebStateHost {
|
|
|
31
35
|
destroyAndWait(): Promise<void>;
|
|
32
36
|
}
|
|
33
37
|
|
|
38
|
+
/** Decide a command timeout's consequence. omp runs RPC handlers one at a
|
|
39
|
+
* time, so a late `get_state`/`prompt` ack is usually queued behind the
|
|
40
|
+
* running turn (or behind a subagent's spawn): resetting that child would
|
|
41
|
+
* throw away a live turn and every subagent it owns. Only a session that is
|
|
42
|
+
* demonstrably idle AND unresponsive is reclaimed.
|
|
43
|
+
*
|
|
44
|
+
* Never throws `session_unresponsive` for a busy session, and never suggests a
|
|
45
|
+
* retry of a command whose acceptance is unknown. */
|
|
46
|
+
async function settleCommandTimeout(host: SessionCommandHost): Promise<never> {
|
|
47
|
+
if (host.isBusy()) throw new WebRpcError(SESSION_BUSY_MESSAGE, 'session_busy');
|
|
48
|
+
await host.destroyAndWait();
|
|
49
|
+
throw new WebRpcError('The OMP session stopped responding and was reset.', 'session_unresponsive');
|
|
50
|
+
}
|
|
51
|
+
|
|
34
52
|
export async function dispatchSessionCommand(host: SessionCommandHost, command: Record<string, unknown>): Promise<unknown> {
|
|
35
53
|
if (host.restarting) throw new WebRpcError(RESTARTING_MESSAGE, 'session_restarting');
|
|
36
54
|
if (!host.isAlive()) throw new Error('Session is no longer running');
|
|
@@ -78,10 +96,10 @@ export async function dispatchSessionCommand(host: SessionCommandHost, command:
|
|
|
78
96
|
host.awaitingAgentStart = false;
|
|
79
97
|
host.awaitingAgentStartDeadline = 0;
|
|
80
98
|
notifyRunningChange();
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
99
|
+
// The ack may simply be queued behind a running turn: omp accepts the
|
|
100
|
+
// prompt before the turn it starts. No reset, and the client must not
|
|
101
|
+
// resend — a duplicate prompt would run twice.
|
|
102
|
+
if (error instanceof RpcCommandTimeoutError) await settleCommandTimeout(host);
|
|
85
103
|
throw error;
|
|
86
104
|
} finally {
|
|
87
105
|
if (!streamingBehavior) {
|
|
@@ -102,15 +120,20 @@ export async function dispatchSessionCommand(host: SessionCommandHost, command:
|
|
|
102
120
|
});
|
|
103
121
|
return null;
|
|
104
122
|
|
|
123
|
+
// Escape hatch behind the Stop button: `abort` resolves only once the turn
|
|
124
|
+
// actually stops, so a session wedged on a subagent never answers it. The
|
|
125
|
+
// caller (shared/lib/chat/omp/abort.ts) escalates to this after a grace
|
|
126
|
+
// period and accepts losing the in-flight work.
|
|
127
|
+
case 'force_reset':
|
|
128
|
+
await host.destroyAndWait();
|
|
129
|
+
return null;
|
|
130
|
+
|
|
105
131
|
case 'get_state': {
|
|
106
132
|
try {
|
|
107
133
|
const state = await host.proc.sendCommand<RpcSessionState>({ type: 'get_state' }, GET_STATE_TIMEOUT_MS);
|
|
108
134
|
return buildWebState(host, state);
|
|
109
135
|
} catch (error) {
|
|
110
|
-
if (error instanceof RpcCommandTimeoutError)
|
|
111
|
-
await host.destroyAndWait();
|
|
112
|
-
throw new WebRpcError('The OMP session stopped responding and was reset.', 'session_unresponsive');
|
|
113
|
-
}
|
|
136
|
+
if (error instanceof RpcCommandTimeoutError) await settleCommandTimeout(host);
|
|
114
137
|
throw error;
|
|
115
138
|
}
|
|
116
139
|
}
|
|
@@ -131,8 +131,9 @@ export function getSpawnApprovalMode(session: AgentSessionWrapper): ApprovalMode
|
|
|
131
131
|
* Returns true when the session was destroyed (caller MUST respawn). */
|
|
132
132
|
export async function reconcileSpawnApprovalMode(session: AgentSessionWrapper, desired: ApprovalMode): Promise<boolean> {
|
|
133
133
|
if (getSpawnApprovalMode(session) === desired) return false;
|
|
134
|
-
// Never kill
|
|
135
|
-
|
|
134
|
+
// Never kill in-flight work — the caller would lose the active turn, and a
|
|
135
|
+
// live subagent outlives that turn.
|
|
136
|
+
if (session.isBusy()) return false;
|
|
136
137
|
// A brand-new session has no JSONL on disk yet; destroying it would 404 the
|
|
137
138
|
// next request that tries to resolve its file.
|
|
138
139
|
if (!session.sessionFile) return false;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import { SubagentLiveness } from '@/server/lib/omp/rpc/subagent-liveness';
|
|
9
|
+
|
|
10
|
+
const NOW = 1_000_000;
|
|
11
|
+
const STALE_MS = 60_000;
|
|
12
|
+
|
|
13
|
+
describe('SubagentLiveness', () => {
|
|
14
|
+
test('counts a started subagent until its terminal lifecycle arrives', () => {
|
|
15
|
+
const live = new SubagentLiveness();
|
|
16
|
+
live.observe({ type: 'subagent_lifecycle', payload: { id: 's1', status: 'started', index: 0 } }, NOW);
|
|
17
|
+
expect(live.liveCount(NOW, STALE_MS)).toBe(1);
|
|
18
|
+
|
|
19
|
+
live.observe({ type: 'subagent_lifecycle', payload: { id: 's1', status: 'completed', index: 0 } }, NOW + 1);
|
|
20
|
+
expect(live.liveCount(NOW + 1, STALE_MS)).toBe(0);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('an id-less progress frame lands on the lifecycle entry it names', () => {
|
|
24
|
+
const live = new SubagentLiveness();
|
|
25
|
+
live.observe({ type: 'subagent_lifecycle', payload: { id: 's1', status: 'started', index: 3 } }, NOW);
|
|
26
|
+
live.observe({ type: 'subagent_progress', payload: { index: 3, status: 'running' } }, NOW + 1);
|
|
27
|
+
expect(live.liveCount(NOW + 1, STALE_MS)).toBe(1);
|
|
28
|
+
|
|
29
|
+
// A terminal progress frame for the same index must clear that one entry —
|
|
30
|
+
// not leave a second key behind holding the session open.
|
|
31
|
+
live.observe({ type: 'subagent_progress', payload: { index: 3, status: 'completed' } }, NOW + 2);
|
|
32
|
+
expect(live.liveCount(NOW + 2, STALE_MS)).toBe(0);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('reads a progress snapshot nested under `progress`', () => {
|
|
36
|
+
const live = new SubagentLiveness();
|
|
37
|
+
live.observe({ type: 'subagent_progress', payload: { progress: { id: 's9', status: 'running' } } }, NOW);
|
|
38
|
+
expect(live.liveCount(NOW, STALE_MS)).toBe(1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('malformed frames never fabricate liveness', () => {
|
|
42
|
+
const live = new SubagentLiveness();
|
|
43
|
+
live.observe({ type: 'subagent_lifecycle', payload: { id: 's1', status: 'paused' } }, NOW);
|
|
44
|
+
live.observe({ type: 'subagent_lifecycle', payload: { status: 'started' } }, NOW);
|
|
45
|
+
live.observe({ type: 'subagent_progress', payload: 'nonsense' }, NOW);
|
|
46
|
+
live.observe({ type: 'subagent_event', payload: { event: { type: 'message_end' } } }, NOW);
|
|
47
|
+
expect(live.liveCount(NOW, STALE_MS)).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('a subagent silent past the stale window stops counting, so one lost terminal frame cannot pin a session', () => {
|
|
51
|
+
const live = new SubagentLiveness();
|
|
52
|
+
live.observe({ type: 'subagent_lifecycle', payload: { id: 's1', status: 'started', index: 0 } }, NOW);
|
|
53
|
+
expect(live.liveCount(NOW + STALE_MS + 1, STALE_MS)).toBe(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('an unattributed event refreshes tracked work without inventing an entry', () => {
|
|
57
|
+
const live = new SubagentLiveness();
|
|
58
|
+
live.observe({ type: 'subagent_lifecycle', payload: { id: 's1', status: 'started', index: 0 } }, NOW);
|
|
59
|
+
live.observe({ type: 'subagent_event', payload: { event: { type: 'tool_execution_start', toolName: 'bash' } } }, NOW + STALE_MS - 1);
|
|
60
|
+
expect(live.liveCount(NOW + STALE_MS + 1, STALE_MS)).toBe(1);
|
|
61
|
+
});
|
|
62
|
+
});
|