@vantran-se/openchamber-web 1.22.1 → 1.23.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 +2 -3
- package/bin/cli.js +11 -1
- package/bin/cli.test.js +92 -0
- package/bin/lib/cli-startup.js +49 -0
- package/bin/lib/commands-startup.js +41 -6
- package/dist/assets/{CodeMirrorCommentWidgets-C53Q7pbX.js → CodeMirrorCommentWidgets-DFg2wnpo.js} +1 -1
- package/dist/assets/DiffView-3Dn4QHbu.js +4 -0
- package/dist/assets/DirtyBranchSwitchDialog-CwOerqVi.js +1 -0
- package/dist/assets/FilesView-BdTOpew9.js +17 -0
- package/dist/assets/GitView-Dqf9vHJh.js +6 -0
- package/dist/assets/{JsonTreeView-Bl0lp9Rs.js → JsonTreeView-BKdsjg5B.js} +1 -1
- package/dist/assets/LinearIssuesView-CmbeL7I6.js +1 -0
- package/dist/assets/MarkdownImageGallery-_5gEVr0X.js +1 -0
- package/dist/assets/MarkdownRendererImpl-wVZlx4dd.js +12 -0
- package/dist/assets/OnboardingScreen-DdP4_nuc.js +1 -0
- package/dist/assets/{PatchDiff-D6NXAfJk.js → PatchDiff-am3SAss2.js} +1 -1
- package/dist/assets/PierreDiffViewer-DCsydlKs.js +161 -0
- package/dist/assets/PlanView-BoJxdRZe.js +5 -0
- package/dist/assets/SettingsView-CcM8pF5O.js +28 -0
- package/dist/assets/{SettingsWindow-BiWDBRvb.js → SettingsWindow-DVTdDSol.js} +1 -1
- package/dist/assets/SymbolsNerdFontMono-Regular-aK5vsLov.woff2 +0 -0
- package/dist/assets/{ToolOutputDialog-BQexMQIm.js → ToolOutputDialog-BgfTbDZ4.js} +6 -6
- package/dist/assets/ToolPartDiffPreview-C7WLk5i9.js +8 -0
- package/dist/assets/{VirtualizedFileDiff-Dpz2ra8h.js → VirtualizedFileDiff-DcdgkH-x.js} +1 -1
- package/dist/assets/WalkthroughView-BEVUDxuw.js +4 -0
- package/dist/assets/{computeVirtualFileMetrics-DtyozJdt.js → computeVirtualFileMetrics-C8byMnRh.js} +2 -2
- package/dist/assets/contextPanelEmbeddedChat-CirXEPJq.js +1 -0
- package/dist/assets/de-C8jV7J9y.js +10 -0
- package/dist/assets/es-BI6J1M5R.js +16 -0
- package/dist/assets/fr-D6oDFt7E.js +14 -0
- package/dist/assets/ghostty-vt-DdA0Zryv.wasm +0 -0
- package/dist/assets/{index-DmhujXbT.js → index-BB7ryz8y.js} +1 -1
- package/dist/assets/index-BqhiCmYS.js +39 -0
- package/dist/assets/index-FqSOBz0J.css +1 -0
- package/dist/assets/{isDiffPlainText-XyulMHxx.js → isDiffPlainText-45ujMPGb.js} +1 -1
- package/dist/assets/{isFilePlainText-B19Ddps1.js → isFilePlainText-Cc4KFSYi.js} +1 -1
- package/dist/assets/ja-C_4i5EQu.js +16 -0
- package/dist/assets/ko-BFbOTpbq.js +16 -0
- package/dist/assets/{main-B2a_JfKZ.js → main-C9ETLKFc.js} +2 -2
- package/dist/assets/main-CJmb2dF8.js +930 -0
- package/dist/assets/markdownCore-DVuZpnli.js +5 -0
- package/dist/assets/{miniChat-B3CSU_Ps.js → miniChat-B4Ytoq2n.js} +2 -2
- package/dist/assets/{mobile-QNcFIVCW.js → mobile-BUT6-lo1.js} +2 -2
- package/dist/assets/patchFileDiff-8j1apICU.js +13 -0
- package/dist/assets/pl-COFvT56l.js +16 -0
- package/dist/assets/pt-BR-DtVOO2Lj.js +16 -0
- package/dist/assets/renderElectronMiniChatApp-B6vUG-vF.js +2 -0
- package/dist/assets/renderMobileApp-BNgHraq6.js +25 -0
- package/dist/assets/runtimeEndpointReset-B8LYpWvr.js +17 -0
- package/dist/assets/scheduledTasksApi-GC4epIYA.js +1 -0
- package/dist/assets/{shikiHighlight-DE-OwqGw.js → shikiHighlight-BJISeZsQ.js} +1 -1
- package/dist/assets/surface-BvRzS58U.js +1 -0
- package/dist/assets/tr-B8Q0Go2l.js +7 -0
- package/dist/assets/uk-Dk4a-Fn-.js +16 -0
- package/dist/assets/{useAppFontEffects-COAeIX-D.js → useAppFontEffects-DpFNefUA.js} +153 -179
- package/dist/assets/useGitComparison-Dfxaqd0s.js +1 -0
- package/dist/assets/useInlineCommentController-CscsE4t-.js +2 -0
- package/dist/assets/useRootScrollLock-BaCvuRlT.js +1 -0
- package/dist/assets/{vendor-base-ui-BeMD8X_w.js → vendor-base-ui-DdV8vhHn.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-data-BG3v1Ghq.js → vendor-codemirror-language-data-DzlSIu_W.js} +2 -2
- package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
- package/dist/assets/vendor-zod-BPAANqvH.js +39 -0
- package/dist/assets/{web-components-BNMwa0qm.js → web-components-CdGfR2XJ.js} +1 -1
- package/dist/assets/zh-CN-B_BXxG1Q.js +16 -0
- package/dist/assets/zh-TW-DLrl1SPZ.js +16 -0
- package/dist/index.html +5 -43
- package/dist/mini-chat.html +4 -4
- package/dist/mobile.html +4 -4
- package/package.json +8 -5
- package/server/index.d.ts +15 -0
- package/server/index.js +82 -13
- package/server/lib/agent-tool/runtime.js +2 -19
- package/server/lib/changelog/update-notes.js +82 -0
- package/server/lib/changelog/update-notes.test.js +59 -0
- package/server/lib/data-dir-migration.js +40 -0
- package/server/lib/data-dir-migration.test.js +59 -0
- package/server/lib/dev-tunnel/DOCUMENTATION.md +13 -4
- package/server/lib/dev-tunnel/runtime.js +4 -4
- package/server/lib/dev-tunnel/tunnel.test.js +20 -0
- package/server/lib/event-stream/runtime.test.js +12 -12
- package/server/lib/fs/DOCUMENTATION.md +5 -0
- package/server/lib/fs/routes.js +63 -21
- package/server/lib/fs/routes.test.js +187 -1
- package/server/lib/git/DOCUMENTATION.md +32 -6
- package/server/lib/git/routes.js +40 -4
- package/server/lib/git/service.js +291 -215
- package/server/lib/git/service.test.js +593 -25
- package/server/lib/inherited-env.js +16 -9
- package/server/lib/inherited-env.test.js +8 -9
- package/server/lib/mcp-reconnect/DOCUMENTATION.md +55 -0
- package/server/lib/mcp-reconnect/runtime.js +122 -0
- package/server/lib/mcp-reconnect/runtime.test.js +140 -0
- package/server/lib/message-queue/DOCUMENTATION.md +161 -0
- package/server/lib/message-queue/runtime.js +870 -0
- package/server/lib/message-queue/runtime.test.js +482 -0
- package/server/lib/network-defaults.js +23 -0
- package/server/lib/network-defaults.test.js +52 -0
- package/server/lib/openchamber-sessions/routes.js +102 -0
- package/server/lib/openchamber-sessions/routes.test.js +88 -0
- package/server/lib/opencode/DOCUMENTATION.md +30 -6
- package/server/lib/opencode/bootstrap-runtime.js +2 -0
- package/server/lib/opencode/config-paths.test.js +116 -0
- package/server/lib/opencode/core-routes.js +1 -0
- package/server/lib/opencode/feature-routes-runtime.js +7 -0
- package/server/lib/opencode/input-history-scope.js +14 -0
- package/server/lib/opencode/managed-plugin-config.js +35 -0
- package/server/lib/opencode/openchamber-routes.js +64 -4
- package/server/lib/opencode/openchamber-routes.test.js +130 -3
- package/server/lib/opencode/plugins.js +2 -2
- package/server/lib/opencode/providers.js +44 -2
- package/server/lib/opencode/providers.test.js +131 -4
- package/server/lib/opencode/routes.js +9 -7
- package/server/lib/opencode/server-startup-runtime.js +1 -1
- package/server/lib/opencode/settings-files.js +214 -0
- package/server/lib/opencode/settings-helpers.js +113 -41
- package/server/lib/opencode/settings-helpers.test.js +342 -0
- package/server/lib/opencode/settings-registry.json +747 -0
- package/server/lib/opencode/settings-runtime.js +141 -20
- package/server/lib/opencode/settings-runtime.test.js +191 -4
- package/server/lib/opencode/shared.js +4 -1
- package/server/lib/opencode/shutdown-runtime.js +2 -0
- package/server/lib/opencode/skill-routes.js +2 -1
- package/server/lib/opencode/snippets.js +1 -2
- package/server/lib/package-manager.js +4 -29
- package/server/lib/package-manager.test.js +2 -2
- package/server/lib/project-context/DOCUMENTATION.md +25 -1
- package/server/lib/project-context/routes.http.test.js +18 -0
- package/server/lib/project-context/routes.js +15 -0
- package/server/lib/project-context/runtime.js +248 -23
- package/server/lib/project-context/runtime.test.js +106 -3
- package/server/lib/projects/DOCUMENTATION.md +76 -0
- package/server/lib/projects/project-config.js +142 -9
- package/server/lib/projects/project-id.js +17 -0
- package/server/lib/projects/project-setup.js +409 -0
- package/server/lib/projects/project-setup.test.js +557 -0
- package/server/lib/projects/routes.js +59 -0
- package/server/lib/quota/DOCUMENTATION.md +40 -2
- package/server/lib/quota/credentials/providers.js +4 -0
- package/server/lib/quota/credentials/store.js +1 -1
- package/server/lib/quota/credentials/store.test.js +4 -4
- package/server/lib/quota/index.js +1 -0
- package/server/lib/quota/providers/cline-pass.js +125 -0
- package/server/lib/quota/providers/cline-pass.test.js +103 -0
- package/server/lib/quota/providers/exe-dev.js +75 -0
- package/server/lib/quota/providers/exe-dev.test.js +47 -0
- package/server/lib/quota/providers/hyper.js +118 -0
- package/server/lib/quota/providers/hyper.test.js +126 -0
- package/server/lib/quota/providers/index.js +22 -0
- package/server/lib/quota/providers/neuralwatt.js +3 -6
- package/server/lib/quota/providers/neuralwatt.test.js +7 -7
- package/server/lib/quota/providers/ollama-cloud.js +31 -0
- package/server/lib/quota/providers/ollama-cloud.test.js +33 -1
- package/server/lib/quota/providers/opencode-go.js +2 -1
- package/server/lib/quota/providers/opencode-go.test.js +31 -2
- package/server/lib/quota/providers/openrouter.js +119 -24
- package/server/lib/quota/providers/openrouter.test.js +330 -0
- package/server/lib/quota/routes.js +2 -0
- package/server/lib/quota/utils/auth.js +1 -1
- package/server/lib/relay/DOCUMENTATION.md +56 -1
- package/server/lib/relay/cross-compat.test.js +26 -0
- package/server/lib/relay/downstream-scheduler.js +154 -0
- package/server/lib/relay/downstream-scheduler.test.js +162 -0
- package/server/lib/relay/e2ee.js +10 -8
- package/server/lib/relay/flow-control.test.js +177 -0
- package/server/lib/relay/host-client.js +37 -8
- package/server/lib/relay/host-client.test.js +9 -1
- package/server/lib/relay/tunnel-codec.js +9 -0
- package/server/lib/relay/tunnel-host.js +69 -18
- package/server/lib/relay/tunnel-host.test.js +9 -1
- package/server/lib/security/request-security.js +21 -6
- package/server/lib/security/request-security.test.js +42 -8
- package/server/lib/session-assist/runtime.js +7 -10
- package/server/lib/session-goal/DOCUMENTATION.md +20 -7
- package/server/lib/session-goal/create.js +3 -1
- package/server/lib/session-goal/runtime.js +81 -20
- package/server/lib/session-goal/runtime.test.js +426 -0
- package/server/lib/skills-catalog/DOCUMENTATION.md +1 -1
- package/server/lib/skills-catalog/install.js +3 -2
- package/server/lib/small-model/DOCUMENTATION.md +7 -2
- package/server/lib/small-model/call.js +17 -6
- package/server/lib/small-model/call.test.js +52 -0
- package/server/lib/small-model/index.js +7 -11
- package/server/lib/small-model/routes.js +2 -1
- package/server/lib/small-model/runtime-providers.js +15 -7
- package/server/lib/small-model/runtime-providers.test.js +7 -1
- package/server/lib/system-prompt/runtime.js +2 -21
- package/server/lib/terminal/DOCUMENTATION.md +14 -10
- package/server/lib/terminal/runtime.js +190 -31
- package/server/lib/terminal/runtime.test.js +571 -37
- package/server/lib/terminal/shells.js +17 -0
- package/server/lib/terminal/shells.test.js +32 -1
- package/server/lib/ui-auth/ui-auth.js +4 -1
- package/server/lib/ui-auth/ui-auth.test.js +16 -0
- package/server/lib/walkthrough/DOCUMENTATION.md +33 -14
- package/server/lib/walkthrough/index.js +1 -0
- package/server/lib/walkthrough/model-settings.js +8 -12
- package/server/lib/walkthrough/prompt.js +3 -1
- package/server/lib/walkthrough/routes.test.js +26 -5
- package/server/lib/walkthrough/sources.js +23 -2
- package/dist/assets/CommitSection-X-f2RgYr.js +0 -1
- package/dist/assets/DiffView-Dh1ey2x9.js +0 -4
- package/dist/assets/FilesView-3ZYP72ki.js +0 -17
- package/dist/assets/GitView-CMuN7z2Z.js +0 -6
- package/dist/assets/LinearIssuesView-CQiUDU8b.js +0 -1
- package/dist/assets/MarkdownImageGallery-bA0PS-tn.js +0 -1
- package/dist/assets/MarkdownRendererImpl-BGQi2PLq.js +0 -12
- package/dist/assets/OnboardingScreen-D3kYJIb6.js +0 -1
- package/dist/assets/PierreDiffViewer-Ct2A13NO.js +0 -157
- package/dist/assets/PlanView-6AUjzNQn.js +0 -5
- package/dist/assets/SettingsView-CHI8fFaK.js +0 -28
- package/dist/assets/ToolPartDiffPreview-DPAATNn6.js +0 -8
- package/dist/assets/WalkthroughView-AX-u2pFg.js +0 -4
- package/dist/assets/contextPanelEmbeddedChat-C9EcRSDF.js +0 -1
- package/dist/assets/de-DqVn5vme.js +0 -9
- package/dist/assets/es-hj43Z53i.js +0 -15
- package/dist/assets/fr-BmkZt2mF.js +0 -13
- package/dist/assets/index-L_ncV-6H.css +0 -1
- package/dist/assets/index-qES19dlJ.js +0 -7
- package/dist/assets/ja-DioMIEGF.js +0 -15
- package/dist/assets/ko-BqPgaQJb.js +0 -15
- package/dist/assets/main-CYgSu6dx.js +0 -928
- package/dist/assets/markdownCore-CWRCqSH2.js +0 -3
- package/dist/assets/patchFileDiff-BpMQZnYu.js +0 -10
- package/dist/assets/pl-slULBF4R.js +0 -15
- package/dist/assets/pt-BR-BqH8fuCH.js +0 -15
- package/dist/assets/renderElectronMiniChatApp-D77bmLqo.js +0 -2
- package/dist/assets/renderMobileApp-BQ3Kdm-d.js +0 -25
- package/dist/assets/runtimeEndpointReset-CRGn1gVj.js +0 -16
- package/dist/assets/scheduledTasksApi-BSRCtP6T.js +0 -1
- package/dist/assets/tr-CjEbw1Aw.js +0 -6
- package/dist/assets/uk-BkkfxbOs.js +0 -15
- package/dist/assets/useInlineCommentController-JsBboQwt.js +0 -2
- package/dist/assets/useRootScrollLock-C0N9mCwz.js +0 -1
- package/dist/assets/vendor-ghostty-web-CrxinGGH.js +0 -13
- package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
- package/dist/assets/vendor-zod-BLfBSgBh.js +0 -39
- package/dist/assets/zh-CN-C6FMdVgv.js +0 -15
- package/dist/assets/zh-TW-CdcMjDxv.js +0 -15
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
createFragmentAssembler,
|
|
16
16
|
decodeJsonPayload,
|
|
17
17
|
decodeTunnelFrame,
|
|
18
|
-
encodeFragmentedMessage,
|
|
19
18
|
encodeJsonPayload,
|
|
20
19
|
encodeTunnelFrame,
|
|
21
20
|
} from './tunnel-codec.js';
|
|
@@ -33,7 +32,9 @@ const ALLOWED_WS_PATHS = new Set([
|
|
|
33
32
|
'/api/event/ws',
|
|
34
33
|
'/api/terminal/ws',
|
|
35
34
|
'/api/dictation/ws',
|
|
35
|
+
'/api/dev-tunnel',
|
|
36
36
|
]);
|
|
37
|
+
export const isAllowedRelayWebSocketPath = (pathname) => ALLOWED_WS_PATHS.has(pathname);
|
|
37
38
|
|
|
38
39
|
// Hop-by-hop headers stripped from tunneled requests; `host` is set by fetch
|
|
39
40
|
// to the loopback origin. content-length is dropped too because the body is
|
|
@@ -72,6 +73,11 @@ const BODY_BUFFER_MAX_BYTES = 512 * 1024;
|
|
|
72
73
|
// completes, so a stalled tunnel converts into an ambiguous transport failure
|
|
73
74
|
// (which the client already retries) instead of a hung loopback request.
|
|
74
75
|
const BODY_DELIVERY_TIMEOUT_MS = 15_000;
|
|
76
|
+
const MAX_PENDING_WS_BYTES = 16 * 1024 * 1024;
|
|
77
|
+
const MAX_PENDING_WS_MESSAGES = 1024;
|
|
78
|
+
// Node ws supports read backpressure. Bun's ws shim does not implement pause;
|
|
79
|
+
// there the bounded queue fails the affected substream explicitly on overflow.
|
|
80
|
+
const CAN_PAUSE_WS_READS = !process.versions.bun;
|
|
75
81
|
|
|
76
82
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
77
83
|
|
|
@@ -97,13 +103,17 @@ const isWsClosePayload = (parsed) => Boolean(parsed && typeof parsed === 'object
|
|
|
97
103
|
* sendFrame: (plaintextFrame: Uint8Array) => void | Promise<void>,
|
|
98
104
|
* getBufferedAmount: () => number,
|
|
99
105
|
* bodyDeliveryTimeoutMs?: number,
|
|
106
|
+
* responseChunkBytes?: number,
|
|
107
|
+
* cancelPendingFrames?: (streamId: number) => void,
|
|
100
108
|
* }} deps
|
|
101
109
|
*/
|
|
102
|
-
export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBufferedAmount, bodyDeliveryTimeoutMs = BODY_DELIVERY_TIMEOUT_MS }) => {
|
|
110
|
+
export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBufferedAmount, bodyDeliveryTimeoutMs = BODY_DELIVERY_TIMEOUT_MS, responseChunkBytes = MAX_TUNNEL_PAYLOAD_BYTES, cancelPendingFrames = () => {} }) => {
|
|
103
111
|
/** @type {Map<number, { kind: 'http', abort: AbortController, body: { enqueue(payload: Uint8Array): void, close(): void, error(error: Error): void } | null, noBody: boolean } | { kind: 'ws', socket: WebSocket, opened: boolean }>} */
|
|
104
112
|
const streams = new Map();
|
|
105
113
|
const assembler = createFragmentAssembler();
|
|
106
114
|
let closed = false;
|
|
115
|
+
let pendingWsBytes = 0;
|
|
116
|
+
let pendingWsMessages = 0;
|
|
107
117
|
|
|
108
118
|
const send = async (frame) => {
|
|
109
119
|
if (closed) return;
|
|
@@ -126,6 +136,7 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
|
|
|
126
136
|
const stream = streams.get(streamId);
|
|
127
137
|
if (!stream) return;
|
|
128
138
|
dropStream(streamId);
|
|
139
|
+
cancelPendingFrames(streamId);
|
|
129
140
|
if (stream.kind === 'http') {
|
|
130
141
|
try {
|
|
131
142
|
stream.body?.error(new Error(String(reason ?? 'aborted')));
|
|
@@ -203,17 +214,27 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
|
|
|
203
214
|
if (STRIPPED_RESPONSE_HEADERS.has(name)) continue;
|
|
204
215
|
responseHeaders[name] = value;
|
|
205
216
|
}
|
|
206
|
-
|
|
217
|
+
if (closed || stream.abort.signal.aborted) {
|
|
218
|
+
await response.body?.cancel();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
207
221
|
|
|
208
222
|
try {
|
|
223
|
+
await sendJson(TunnelFrameType.HttpResponse, streamId, { status: response.status, headers: responseHeaders });
|
|
209
224
|
if (response.body) {
|
|
210
225
|
for await (const chunk of response.body) {
|
|
211
226
|
if (closed || stream.abort.signal.aborted) return;
|
|
212
227
|
const bytes = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
|
|
213
|
-
|
|
228
|
+
const pieces = chunkPayload(bytes, responseChunkBytes);
|
|
229
|
+
// Offer at most one plaintext-frame budget at a time. The scheduler
|
|
230
|
+
// can batch small slices and interleave streams without buffering the
|
|
231
|
+
// entire source or encrypting hundreds of tiny messages separately.
|
|
232
|
+
const groupSize = Math.max(1, Math.floor(MAX_TUNNEL_PAYLOAD_BYTES / responseChunkBytes));
|
|
233
|
+
for (let offset = 0; offset < pieces.length; offset += groupSize) {
|
|
214
234
|
await waitForBackpressure(stream.abort.signal);
|
|
215
235
|
if (closed || stream.abort.signal.aborted) return;
|
|
216
|
-
await
|
|
236
|
+
await Promise.all(pieces.slice(offset, offset + groupSize).map(piece =>
|
|
237
|
+
send(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, piece))));
|
|
217
238
|
}
|
|
218
239
|
}
|
|
219
240
|
}
|
|
@@ -425,7 +446,7 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
|
|
|
425
446
|
void sendAbort(streamId, error?.message ?? 'malformed ws open');
|
|
426
447
|
return;
|
|
427
448
|
}
|
|
428
|
-
if (!
|
|
449
|
+
if (!isAllowedRelayWebSocketPath(open.path)) {
|
|
429
450
|
void sendAbort(streamId, 'Path is not allowed through the relay');
|
|
430
451
|
return;
|
|
431
452
|
}
|
|
@@ -446,6 +467,7 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
|
|
|
446
467
|
try {
|
|
447
468
|
socket = new WebSocket(url, open.protocols, {
|
|
448
469
|
headers: dialHeaders,
|
|
470
|
+
maxPayload: MAX_PENDING_WS_BYTES,
|
|
449
471
|
});
|
|
450
472
|
} catch (error) {
|
|
451
473
|
void sendAbort(streamId, error?.message ?? 'ws dial failed');
|
|
@@ -453,6 +475,8 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
|
|
|
453
475
|
}
|
|
454
476
|
const stream = { kind: 'ws', socket, opened: false };
|
|
455
477
|
streams.set(streamId, stream);
|
|
478
|
+
let outputChain = Promise.resolve();
|
|
479
|
+
let socketPendingMessages = 0;
|
|
456
480
|
|
|
457
481
|
socket.on('open', () => {
|
|
458
482
|
if (streams.get(streamId) !== stream) return;
|
|
@@ -463,23 +487,47 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
|
|
|
463
487
|
if (streams.get(streamId) !== stream || closed) return;
|
|
464
488
|
const bytes = Buffer.isBuffer(data) ? new Uint8Array(data) : new Uint8Array(Buffer.concat(data));
|
|
465
489
|
const frameType = isBinary ? TunnelFrameType.WsBinary : TunnelFrameType.WsText;
|
|
466
|
-
|
|
467
|
-
|
|
490
|
+
if (pendingWsBytes + bytes.length > MAX_PENDING_WS_BYTES || pendingWsMessages >= MAX_PENDING_WS_MESSAGES) {
|
|
491
|
+
abortLocalStream(streamId, 'upstream WebSocket exceeded downstream queue limit');
|
|
492
|
+
void sendAbort(streamId, 'upstream WebSocket exceeded downstream queue limit');
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
pendingWsBytes += bytes.length;
|
|
496
|
+
pendingWsMessages += 1;
|
|
497
|
+
socketPendingMessages += 1;
|
|
498
|
+
if (CAN_PAUSE_WS_READS) socket.pause();
|
|
499
|
+
outputChain = outputChain.then(async () => {
|
|
500
|
+
// Serialize entire messages, including their fragments. A later close
|
|
501
|
+
// must also wait here or it can overtake the final output.
|
|
502
|
+
const chunks = chunkPayload(bytes, responseChunkBytes);
|
|
503
|
+
const groupSize = Math.max(1, Math.floor(MAX_TUNNEL_PAYLOAD_BYTES / responseChunkBytes));
|
|
504
|
+
for (let offset = 0; offset < chunks.length; offset += groupSize) {
|
|
468
505
|
await waitForBackpressure(null);
|
|
469
506
|
if (streams.get(streamId) !== stream || closed) return;
|
|
470
|
-
await
|
|
507
|
+
await Promise.all(chunks.slice(offset, offset + groupSize).map((chunk, index) =>
|
|
508
|
+
send(encodeTunnelFrame(frameType, streamId, chunk, offset + index < chunks.length - 1))));
|
|
471
509
|
}
|
|
472
|
-
})()
|
|
510
|
+
}).catch(() => {
|
|
511
|
+
abortLocalStream(streamId, 'upstream WebSocket forwarding failed');
|
|
512
|
+
void sendAbort(streamId, 'upstream WebSocket forwarding failed');
|
|
513
|
+
}).finally(() => {
|
|
514
|
+
pendingWsBytes -= bytes.length;
|
|
515
|
+
pendingWsMessages -= 1;
|
|
516
|
+
socketPendingMessages -= 1;
|
|
517
|
+
if (CAN_PAUSE_WS_READS && socketPendingMessages === 0 && streams.get(streamId) === stream && socket.readyState === WebSocket.OPEN) socket.resume();
|
|
518
|
+
});
|
|
473
519
|
});
|
|
474
520
|
socket.on('close', (code, reasonBuffer) => {
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
521
|
+
void outputChain.then(async () => {
|
|
522
|
+
if (streams.get(streamId) !== stream) return;
|
|
523
|
+
dropStream(streamId);
|
|
524
|
+
const reason = reasonBuffer ? reasonBuffer.toString('utf8') : '';
|
|
525
|
+
if (stream.opened) {
|
|
526
|
+
await sendJson(TunnelFrameType.WsClose, streamId, { code: code || 1000, reason });
|
|
527
|
+
} else {
|
|
528
|
+
await sendAbort(streamId, reason || `upstream ws closed (${code || 'no code'})`);
|
|
529
|
+
}
|
|
530
|
+
});
|
|
483
531
|
});
|
|
484
532
|
socket.on('error', (error) => {
|
|
485
533
|
if (streams.get(streamId) !== stream) return;
|
|
@@ -510,6 +558,7 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
|
|
|
510
558
|
const stream = streams.get(streamId);
|
|
511
559
|
if (!stream || stream.kind !== 'ws') return;
|
|
512
560
|
dropStream(streamId);
|
|
561
|
+
cancelPendingFrames(streamId);
|
|
513
562
|
let close = { code: 1000, reason: '' };
|
|
514
563
|
try {
|
|
515
564
|
close = decodeJsonPayload(payload, isWsClosePayload);
|
|
@@ -518,6 +567,8 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
|
|
|
518
567
|
}
|
|
519
568
|
const code = Number.isInteger(close.code) && close.code >= 1000 && close.code <= 4999 ? close.code : 1000;
|
|
520
569
|
try {
|
|
570
|
+
// A paused receiver still needs to read the peer's close handshake.
|
|
571
|
+
if (CAN_PAUSE_WS_READS) stream.socket.resume();
|
|
521
572
|
stream.socket.close(code, typeof close.reason === 'string' ? close.reason : '');
|
|
522
573
|
} catch {
|
|
523
574
|
stream.socket.terminate();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, test, expect } from 'bun:test';
|
|
2
2
|
import http from 'node:http';
|
|
3
3
|
|
|
4
|
-
import { createTunnelHost } from './tunnel-host.js';
|
|
4
|
+
import { createTunnelHost, isAllowedRelayWebSocketPath } from './tunnel-host.js';
|
|
5
5
|
import { decodeTunnelFrame, encodeTunnelFrame, encodeJsonPayload, TunnelFrameType } from './tunnel-codec.js';
|
|
6
6
|
|
|
7
7
|
const startLoopback = () =>
|
|
@@ -145,3 +145,11 @@ describe('tunnel-host HTTP body forwarding', () => {
|
|
|
145
145
|
await loopback.stop();
|
|
146
146
|
});
|
|
147
147
|
});
|
|
148
|
+
|
|
149
|
+
describe('relay host WebSocket allowlist', () => {
|
|
150
|
+
test('allows only the exact dev-server tunnel path', () => {
|
|
151
|
+
expect(isAllowedRelayWebSocketPath('/api/dev-tunnel')).toBe(true);
|
|
152
|
+
expect(isAllowedRelayWebSocketPath('/api/dev-tunnel/')).toBe(false);
|
|
153
|
+
expect(isAllowedRelayWebSocketPath('/api/database/ws')).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -67,6 +67,7 @@ export const createRequestSecurityRuntime = (deps) => {
|
|
|
67
67
|
|
|
68
68
|
const getRequestOriginCandidates = async (req) => {
|
|
69
69
|
const origins = new Set();
|
|
70
|
+
const hosts = new Set();
|
|
70
71
|
const forwardedProto = typeof req.headers['x-forwarded-proto'] === 'string'
|
|
71
72
|
? req.headers['x-forwarded-proto'].split(',')[0].trim().toLowerCase()
|
|
72
73
|
: '';
|
|
@@ -78,6 +79,7 @@ export const createRequestSecurityRuntime = (deps) => {
|
|
|
78
79
|
const host = forwardedHost || (typeof req.headers.host === 'string' ? req.headers.host.trim() : '');
|
|
79
80
|
|
|
80
81
|
if (host) {
|
|
82
|
+
hosts.add(host.toLowerCase());
|
|
81
83
|
origins.add(`${protocol}://${host}`);
|
|
82
84
|
const [hostname, port] = host.split(':');
|
|
83
85
|
const normalizedHost = typeof hostname === 'string' ? hostname.toLowerCase() : '';
|
|
@@ -98,10 +100,10 @@ export const createRequestSecurityRuntime = (deps) => {
|
|
|
98
100
|
} catch {
|
|
99
101
|
}
|
|
100
102
|
|
|
101
|
-
return origins;
|
|
103
|
+
return { origins, hosts };
|
|
102
104
|
};
|
|
103
105
|
|
|
104
|
-
const isRequestOriginAllowed =
|
|
106
|
+
const isRequestOriginAllowed = (req) => {
|
|
105
107
|
const originHeader = typeof req.headers.origin === 'string' ? req.headers.origin.trim() : '';
|
|
106
108
|
if (!originHeader) {
|
|
107
109
|
return false;
|
|
@@ -111,15 +113,28 @@ export const createRequestSecurityRuntime = (deps) => {
|
|
|
111
113
|
return true;
|
|
112
114
|
}
|
|
113
115
|
|
|
114
|
-
let
|
|
116
|
+
let origin;
|
|
115
117
|
try {
|
|
116
|
-
|
|
118
|
+
origin = new URL(originHeader);
|
|
117
119
|
} catch {
|
|
118
120
|
return false;
|
|
119
121
|
}
|
|
120
122
|
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
+
const forwardedHostHeader = req.headers['x-forwarded-host'];
|
|
124
|
+
const forwardedHost = (Array.isArray(forwardedHostHeader) ? forwardedHostHeader[0] : forwardedHostHeader || '')
|
|
125
|
+
.split(',')[0].trim().toLowerCase();
|
|
126
|
+
const hostHeader = req.headers.host;
|
|
127
|
+
const host = forwardedHost || (Array.isArray(hostHeader) ? hostHeader[0] : hostHeader || '').trim().toLowerCase();
|
|
128
|
+
if (host && host === origin.host.toLowerCase()) return true;
|
|
129
|
+
|
|
130
|
+
// TLS commonly ends at a cloud edge before an HTTP hop to OpenChamber.
|
|
131
|
+
// In that setup the browser's Origin is https while a generic reverse
|
|
132
|
+
// proxy reports the upstream request as http. The external host remains
|
|
133
|
+
// authoritative, so compare it directly instead of requiring the proxy to
|
|
134
|
+
// preserve the browser-facing protocol.
|
|
135
|
+
return getRequestOriginCandidates(req).then((candidates) => (
|
|
136
|
+
candidates.origins.has(origin.origin) || candidates.hosts.has(origin.host.toLowerCase())
|
|
137
|
+
));
|
|
123
138
|
};
|
|
124
139
|
|
|
125
140
|
return {
|
|
@@ -9,41 +9,75 @@ describe('request security runtime', () => {
|
|
|
9
9
|
test('allows packaged client origins for remote client transports', async () => {
|
|
10
10
|
const runtime = createRuntime();
|
|
11
11
|
|
|
12
|
-
await
|
|
12
|
+
expect(await runtime.isRequestOriginAllowed({
|
|
13
13
|
headers: {
|
|
14
14
|
origin: 'openchamber-ui://app',
|
|
15
15
|
host: '192.168.1.130:1202',
|
|
16
16
|
},
|
|
17
17
|
socket: {},
|
|
18
|
-
})).
|
|
18
|
+
})).toBe(true);
|
|
19
19
|
|
|
20
|
-
await
|
|
20
|
+
expect(await runtime.isRequestOriginAllowed({
|
|
21
21
|
headers: {
|
|
22
22
|
origin: 'capacitor://localhost',
|
|
23
23
|
host: '192.168.1.130:1202',
|
|
24
24
|
},
|
|
25
25
|
socket: {},
|
|
26
|
-
})).
|
|
26
|
+
})).toBe(true);
|
|
27
27
|
|
|
28
28
|
// Android Capacitor WebView (androidScheme 'https') reports this origin.
|
|
29
|
-
await
|
|
29
|
+
expect(await runtime.isRequestOriginAllowed({
|
|
30
30
|
headers: {
|
|
31
31
|
origin: 'https://localhost',
|
|
32
32
|
host: '192.168.1.130:1202',
|
|
33
33
|
},
|
|
34
34
|
socket: {},
|
|
35
|
-
})).
|
|
35
|
+
})).toBe(true);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
test('rejects unknown origins', async () => {
|
|
39
39
|
const runtime = createRuntime();
|
|
40
40
|
|
|
41
|
-
await
|
|
41
|
+
expect(await runtime.isRequestOriginAllowed({
|
|
42
42
|
headers: {
|
|
43
43
|
origin: 'https://evil.example.com',
|
|
44
44
|
host: '192.168.1.130:1202',
|
|
45
45
|
},
|
|
46
46
|
socket: {},
|
|
47
|
-
})).
|
|
47
|
+
})).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('allows the external host when TLS terminates before an HTTP proxy hop', async () => {
|
|
51
|
+
const runtime = createRuntime();
|
|
52
|
+
|
|
53
|
+
expect(await runtime.isRequestOriginAllowed({
|
|
54
|
+
headers: {
|
|
55
|
+
origin: 'https://devchamber.example.com',
|
|
56
|
+
host: 'devchamber.example.com',
|
|
57
|
+
'x-forwarded-proto': 'http',
|
|
58
|
+
},
|
|
59
|
+
socket: {},
|
|
60
|
+
})).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('uses the forwarded external host without trusting a different origin', async () => {
|
|
64
|
+
const runtime = createRuntime();
|
|
65
|
+
const request = {
|
|
66
|
+
headers: {
|
|
67
|
+
host: '127.0.0.1:3000',
|
|
68
|
+
'x-forwarded-host': 'devchamber.example.com',
|
|
69
|
+
'x-forwarded-proto': 'http',
|
|
70
|
+
},
|
|
71
|
+
socket: {},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
expect(await runtime.isRequestOriginAllowed({
|
|
75
|
+
...request,
|
|
76
|
+
headers: { ...request.headers, origin: 'https://devchamber.example.com' },
|
|
77
|
+
})).toBe(true);
|
|
78
|
+
expect(await runtime.isRequestOriginAllowed({
|
|
79
|
+
...request,
|
|
80
|
+
headers: { ...request.headers, origin: 'https://evil.example.com' },
|
|
81
|
+
})).toBe(false);
|
|
48
82
|
});
|
|
49
83
|
});
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import fs from 'fs';
|
|
12
12
|
import os from 'os';
|
|
13
13
|
import path from 'path';
|
|
14
|
+
import { readMergedSettingsSync } from '../opencode/settings-files.js';
|
|
14
15
|
|
|
15
16
|
const OPENCHAMBER_SETTINGS_FILE = path.join(
|
|
16
17
|
process.env.OPENCHAMBER_DATA_DIR
|
|
@@ -23,16 +24,11 @@ const OPENCHAMBER_SETTINGS_FILE = path.join(
|
|
|
23
24
|
// off, no small-model calls and no metadata writes happen at all. Existing
|
|
24
25
|
// payloads stay untouched — clients keep showing them and dismissal still works.
|
|
25
26
|
const getSessionAssistTargets = () => {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
suggestion: settings?.sessionSuggestionEnabled !== false,
|
|
32
|
-
};
|
|
33
|
-
} catch {
|
|
34
|
-
return { recap: true, suggestion: true };
|
|
35
|
-
}
|
|
27
|
+
const settings = readMergedSettingsSync({ fs, path, settingsFilePath: OPENCHAMBER_SETTINGS_FILE });
|
|
28
|
+
return {
|
|
29
|
+
recap: settings.sessionRecapEnabled !== false,
|
|
30
|
+
suggestion: settings.sessionSuggestionEnabled !== false,
|
|
31
|
+
};
|
|
36
32
|
};
|
|
37
33
|
|
|
38
34
|
const IDLE_QUIET_MS = 60_000;
|
|
@@ -257,6 +253,7 @@ export const createSessionAssistRuntime = ({
|
|
|
257
253
|
prompt: `The latest exchange in the conversation:\n\n${transcript}\n\nWrite ${requestedFields} in the SAME language as this sample from the conversation: "${languageSample}"`,
|
|
258
254
|
system: buildAssistSystemPrompt(targets),
|
|
259
255
|
directory,
|
|
256
|
+
sessionID: sessionId,
|
|
260
257
|
preferredProviderID: typeof lastAssistantInfo.providerID === 'string' ? lastAssistantInfo.providerID : undefined,
|
|
261
258
|
preferredModelID: typeof lastAssistantInfo.modelID === 'string' ? lastAssistantInfo.modelID : undefined,
|
|
262
259
|
});
|
|
@@ -108,13 +108,26 @@ before touching the filesystem). Rationale: metadata rides every
|
|
|
108
108
|
stop), with a tick-side safety net. Messages sent while paused leave
|
|
109
109
|
the goal alone; Resume re-arms the loop, and resuming over an aborted
|
|
110
110
|
tail skips the audit and goes straight to a continuation nudge;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
111
|
+
- terminal checks, cheapest first: assistant turn error → `blocked`;
|
|
112
|
+
`tokensUsed >= tokenBudget` → `budgetLimited`;
|
|
113
|
+
`turnsUsed >= MAX_AUTO_TURNS` (20) → `blocked`;
|
|
114
|
+
- error classification is independent of `finish`: `MessageAbortedError`
|
|
115
|
+
keeps the pause/resume behavior; only a `finish: "length"` with no
|
|
116
|
+
error, or `MessageOutputLengthError`, is an in-progress truncation that
|
|
117
|
+
skips the audit and continues. Any other non-null error wins over a
|
|
118
|
+
length finish and blocks with its non-empty `error.name`, or
|
|
119
|
+
`assistant turn failed` when unnamed;
|
|
120
|
+
- length recovery is bounded separately from the token budget and
|
|
121
|
+
auto-continuation cap: the first truncation permits one continuation, but
|
|
122
|
+
a second consecutive completed, non-summary assistant turn that is also
|
|
123
|
+
truncated settles the goal as `blocked` (`repeated output truncation`).
|
|
124
|
+
The consecutive state is derived from the loaded message history, not
|
|
125
|
+
persisted, using `info.time.created` chronology rather than message IDs.
|
|
126
|
+
Summary messages are not agent turns; an ordinary completed assistant
|
|
127
|
+
turn naturally breaks the consecutive condition. Explicit Resume grants
|
|
128
|
+
one new recovery attempt over the same transcript; the continuation
|
|
129
|
+
consumes that permission, so another truncation blocks again. Resume
|
|
130
|
+
does not bypass assistant errors or the token budget;
|
|
118
131
|
- otherwise, small-model audit of the objective + the last assistant turn
|
|
119
132
|
only — no conversation history and no continuation prompts
|
|
120
133
|
(`restrictToPreferredProvider`, session's own provider/model preferred):
|
|
@@ -14,7 +14,7 @@ export const buildGoalIntroText = (tokenBudget) => {
|
|
|
14
14
|
+ '\n</system-reminder>';
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
-
const fitObjective = async ({ objective, directory, providerID, modelID, warn }) => {
|
|
17
|
+
const fitObjective = async ({ objective, directory, sessionID, providerID, modelID, warn }) => {
|
|
18
18
|
if (objective.length <= GOAL_OBJECTIVE_CHAR_LIMIT) return objective;
|
|
19
19
|
|
|
20
20
|
let distilled = null;
|
|
@@ -32,6 +32,7 @@ const fitObjective = async ({ objective, directory, providerID, modelID, warn })
|
|
|
32
32
|
'Write in the same language as the task text.',
|
|
33
33
|
].join('\n'),
|
|
34
34
|
directory,
|
|
35
|
+
sessionID,
|
|
35
36
|
preferredProviderID: providerID,
|
|
36
37
|
preferredModelID: modelID,
|
|
37
38
|
});
|
|
@@ -66,6 +67,7 @@ export const createSessionGoal = async ({
|
|
|
66
67
|
const objectiveText = await fitObjective({
|
|
67
68
|
objective: String(objective ?? '').trim(),
|
|
68
69
|
directory,
|
|
70
|
+
sessionID,
|
|
69
71
|
providerID,
|
|
70
72
|
modelID,
|
|
71
73
|
warn,
|
|
@@ -19,6 +19,7 @@ import os from 'os';
|
|
|
19
19
|
import path from 'path';
|
|
20
20
|
|
|
21
21
|
import { GOAL_OBJECTIVE_CHAR_LIMIT, readObjective } from './objectives.js';
|
|
22
|
+
import { readMergedSettingsSync } from '../opencode/settings-files.js';
|
|
22
23
|
|
|
23
24
|
const OPENCHAMBER_SETTINGS_FILE = path.join(
|
|
24
25
|
process.env.OPENCHAMBER_DATA_DIR
|
|
@@ -27,15 +28,9 @@ const OPENCHAMBER_SETTINGS_FILE = path.join(
|
|
|
27
28
|
'settings.json',
|
|
28
29
|
);
|
|
29
30
|
|
|
30
|
-
const isSessionGoalEnabled = () =>
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const settings = JSON.parse(raw);
|
|
34
|
-
return settings?.sessionGoalEnabled !== false;
|
|
35
|
-
} catch {
|
|
36
|
-
return true;
|
|
37
|
-
}
|
|
38
|
-
};
|
|
31
|
+
const isSessionGoalEnabled = () => (
|
|
32
|
+
readMergedSettingsSync({ fs, path, settingsFilePath: OPENCHAMBER_SETTINGS_FILE }).sessionGoalEnabled !== false
|
|
33
|
+
);
|
|
39
34
|
|
|
40
35
|
const IDLE_QUIET_MS = 15_000;
|
|
41
36
|
// A goal set while the session is already idle should kick off promptly.
|
|
@@ -244,6 +239,57 @@ const messageTokenTotal = (info) => {
|
|
|
244
239
|
return input + cachedRead + output;
|
|
245
240
|
};
|
|
246
241
|
|
|
242
|
+
const getErrorName = (error) => error?.name?.trim?.() ?? '';
|
|
243
|
+
|
|
244
|
+
const isLengthTruncated = (info, errorName = getErrorName(info?.error)) => {
|
|
245
|
+
const error = info?.error;
|
|
246
|
+
const hasError = error !== null && error !== undefined;
|
|
247
|
+
return errorName === 'MessageOutputLengthError' || (!hasError && info?.finish === 'length');
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
// Summary messages are assistant-shaped, but they are compaction turns rather
|
|
251
|
+
// than agent turns. They must not break or satisfy the consecutive truncation
|
|
252
|
+
// check; only completed, non-summary assistant turns participate. Chronology
|
|
253
|
+
// comes from `time.created`, never from message IDs; array position is only a
|
|
254
|
+
// tie-breaker for equal timestamps.
|
|
255
|
+
const hasRepeatedLengthTail = (messages, latestAssistant, goalCreatedAt) => {
|
|
256
|
+
const latestInfo = latestAssistant?.info;
|
|
257
|
+
if (latestInfo?.summary === true) return false;
|
|
258
|
+
const latestIndex = messages.indexOf(latestAssistant);
|
|
259
|
+
const latestCreated = latestInfo?.time?.created;
|
|
260
|
+
if (
|
|
261
|
+
latestIndex < 0
|
|
262
|
+
|| !(latestInfo?.time?.completed > 0)
|
|
263
|
+
|| !(Number.isFinite(latestCreated) && latestCreated > 0)
|
|
264
|
+
|| !isLengthTruncated(latestInfo)
|
|
265
|
+
) return false;
|
|
266
|
+
|
|
267
|
+
let previous = null;
|
|
268
|
+
for (let i = 0; i < messages.length; i += 1) {
|
|
269
|
+
const info = messages[i]?.info;
|
|
270
|
+
if (info?.role !== 'assistant' || info.summary === true || !(info.time?.completed > 0)) continue;
|
|
271
|
+
const created = info.time?.created;
|
|
272
|
+
// An unknown timestamp cannot safely participate in chronology. Ignore it
|
|
273
|
+
// rather than letting an unrelated older message hide known chronology.
|
|
274
|
+
if (!(Number.isFinite(created) && created > 0)) continue;
|
|
275
|
+
if (i === latestIndex) continue;
|
|
276
|
+
if (created > latestCreated || (created === latestCreated && i > latestIndex)) continue;
|
|
277
|
+
if (
|
|
278
|
+
!previous
|
|
279
|
+
|| created > previous.created
|
|
280
|
+
|| (created === previous.created && i > previous.index)
|
|
281
|
+
) {
|
|
282
|
+
previous = { info, created, index: i };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return Boolean(
|
|
287
|
+
previous
|
|
288
|
+
&& previous.created > goalCreatedAt
|
|
289
|
+
&& isLengthTruncated(previous.info),
|
|
290
|
+
);
|
|
291
|
+
};
|
|
292
|
+
|
|
247
293
|
export const createSessionGoalRuntime = ({
|
|
248
294
|
buildOpenCodeUrl,
|
|
249
295
|
getOpenCodeAuthHeaders,
|
|
@@ -378,6 +424,7 @@ export const createSessionGoalRuntime = ({
|
|
|
378
424
|
prompt: `The goal objective:\n\n<objective>\n${goal.objective}\n</objective>\n\nThe agent's latest turn:\n\n${assistantText}\n\nReturn the verdict JSON. Write the note in the SAME language as this sample from the objective: "${goal.objective.slice(0, 200).replace(/\s+/g, ' ').trim()}"`,
|
|
379
425
|
system: buildAuditSystemPrompt(),
|
|
380
426
|
directory,
|
|
427
|
+
sessionID: typeof lastAssistantInfo?.sessionID === 'string' ? lastAssistantInfo.sessionID : undefined,
|
|
381
428
|
preferredProviderID: typeof lastAssistantInfo?.providerID === 'string' ? lastAssistantInfo.providerID : undefined,
|
|
382
429
|
preferredModelID: typeof lastAssistantInfo?.modelID === 'string' ? lastAssistantInfo.modelID : undefined,
|
|
383
430
|
});
|
|
@@ -608,7 +655,11 @@ export const createSessionGoalRuntime = ({
|
|
|
608
655
|
// resumed over an aborted tail: that is an explicit "keep going", so it
|
|
609
656
|
// falls through to the continuation below (skipping the audit — an
|
|
610
657
|
// aborted reply is not evidence of anything).
|
|
611
|
-
const
|
|
658
|
+
const error = lastAssistantInfo.error;
|
|
659
|
+
const errorName = getErrorName(error);
|
|
660
|
+
const hasError = error !== null && error !== undefined;
|
|
661
|
+
const abortedTail = errorName === 'MessageAbortedError';
|
|
662
|
+
const lengthTail = isLengthTruncated(lastAssistantInfo, errorName);
|
|
612
663
|
if (abortedTail && goal.statusReason !== 'resumed') {
|
|
613
664
|
await writeGoal(sessionId, directory, goal.id, () => ({
|
|
614
665
|
status: 'paused',
|
|
@@ -622,13 +673,12 @@ export const createSessionGoalRuntime = ({
|
|
|
622
673
|
return;
|
|
623
674
|
}
|
|
624
675
|
|
|
625
|
-
//
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
: 'assistant turn failed';
|
|
676
|
+
// Non-length turn error → blocked (prevents runaway auto-continuation into
|
|
677
|
+
// failures). Recognized length cutoffs are in-progress continuations, not
|
|
678
|
+
// hard failures.
|
|
679
|
+
if (!abortedTail && !lengthTail && hasError) {
|
|
630
680
|
await settleGoal({
|
|
631
|
-
sessionId, directory, goal, status: 'blocked', statusReason:
|
|
681
|
+
sessionId, directory, goal, status: 'blocked', statusReason: errorName || 'assistant turn failed', tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID,
|
|
632
682
|
});
|
|
633
683
|
return;
|
|
634
684
|
}
|
|
@@ -649,17 +699,28 @@ export const createSessionGoalRuntime = ({
|
|
|
649
699
|
return;
|
|
650
700
|
}
|
|
651
701
|
|
|
702
|
+
// A second consecutive completed, non-summary length-truncated turn is a
|
|
703
|
+
// bounded recovery failure. Derive this from the loaded transcript rather
|
|
704
|
+
// than persisting another goal counter.
|
|
705
|
+
if (lengthTail && goal.statusReason !== 'resumed' && hasRepeatedLengthTail(messages, lastAssistant, goal.createdAt)) {
|
|
706
|
+
await settleGoal({
|
|
707
|
+
sessionId, directory, goal, status: 'blocked', statusReason: 'repeated output truncation', tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID,
|
|
708
|
+
});
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
652
712
|
// --- Small-model audit: the sole termination authority besides the hard
|
|
653
713
|
// stops above (turn error, budget, continuation cap). The working agent
|
|
654
714
|
// has no channel to settle its own goal.
|
|
655
715
|
//
|
|
656
|
-
// Exception: when the latest message is a compaction summary
|
|
657
|
-
// by
|
|
658
|
-
// "in progress, not finished".
|
|
716
|
+
// Exception: when the latest message is a compaction summary or was cut off
|
|
717
|
+
// by the output token limit (length stop), the agent by definition ran into
|
|
718
|
+
// the context/output limit mid-work — that IS "in progress, not finished".
|
|
719
|
+
// No audit call; continue unconditionally.
|
|
659
720
|
let audit = null;
|
|
660
721
|
let blockedStreak = 0;
|
|
661
722
|
let auditFailStreak = goal.auditFailStreak;
|
|
662
|
-
if (lastAssistantInfo.summary === true || abortedTail) {
|
|
723
|
+
if (lastAssistantInfo.summary === true || abortedTail || lengthTail) {
|
|
663
724
|
blockedStreak = goal.blockedStreak;
|
|
664
725
|
} else {
|
|
665
726
|
audit = await runAudit({ goal: { ...goal, objective: effectiveObjective }, assistantText, directory, lastAssistantInfo: executionInfo ?? lastAssistantInfo });
|