@aiworker/web 1.22.2 → 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/bin/cli.js +7 -0
- package/dist/assets/{CodeMirrorCommentWidgets-CSICpWQD.js → CodeMirrorCommentWidgets-B1Gizi3a.js} +1 -1
- package/dist/assets/DiffView-DXjVsEOw.js +4 -0
- package/dist/assets/{DirtyBranchSwitchDialog-C22HdWj4.js → DirtyBranchSwitchDialog-aig6zJjg.js} +1 -1
- package/dist/assets/FilesView-CJdG0-wk.js +17 -0
- package/dist/assets/{GitView-VQQkYuUw.js → GitView-BXsS3jG6.js} +4 -4
- package/dist/assets/{JsonTreeView-6lrhwD3q.js → JsonTreeView-D_hpXBnF.js} +1 -1
- package/dist/assets/{LinearIssuesView-Ba6Q9569.js → LinearIssuesView-BoUiLQG1.js} +1 -1
- package/dist/assets/MarkdownImageGallery-B_B_a9KS.js +1 -0
- package/dist/assets/MarkdownRendererImpl-xgyyLiBM.js +12 -0
- package/dist/assets/OnboardingScreen-DIp84OWt.js +1 -0
- package/dist/assets/{PatchDiff-BzcZPlOq.js → PatchDiff-8p6nLJaU.js} +1 -1
- package/dist/assets/PierreDiffViewer-DjC8nTk9.js +161 -0
- package/dist/assets/{PlanView-DyDt_nZ_.js → PlanView-CJTmOF6I.js} +2 -2
- package/dist/assets/SettingsView-DD-JqIJx.js +28 -0
- package/dist/assets/{SettingsWindow-DDQYaKWQ.js → SettingsWindow-Cpb4hM6q.js} +1 -1
- package/dist/assets/SymbolsNerdFontMono-Regular-aK5vsLov.woff2 +0 -0
- package/dist/assets/{ToolOutputDialog-iYF8Y2bz.js → ToolOutputDialog-DfaKZXNU.js} +6 -6
- package/dist/assets/{ToolPartDiffPreview-CdjbOVMM.js → ToolPartDiffPreview-DtZLzrSK.js} +2 -2
- package/dist/assets/{VirtualizedFileDiff-fFFm2QgH.js → VirtualizedFileDiff-C8i9HpTB.js} +1 -1
- package/dist/assets/WalkthroughView-DeKMvZlL.js +4 -0
- package/dist/assets/{computeVirtualFileMetrics-CzgLum_K.js → computeVirtualFileMetrics-BBI7ZwrC.js} +2 -2
- package/dist/assets/contextPanelEmbeddedChat-BdA6bdky.js +1 -0
- package/dist/assets/de-DCrsQ5zS.js +10 -0
- package/dist/assets/es-Dwv4YfH6.js +16 -0
- package/dist/assets/fr-BCWg4Cnx.js +14 -0
- package/dist/assets/ghostty-vt-DdA0Zryv.wasm +0 -0
- package/dist/assets/index-B68h5S5B.css +1 -0
- package/dist/assets/index-DVwMarHI.js +39 -0
- package/dist/assets/{index-Bl3LSbjn.js → index-jXSK5RrB.js} +1 -1
- package/dist/assets/{isDiffPlainText-Dx-dB_8l.js → isDiffPlainText-B_x_rVon.js} +1 -1
- package/dist/assets/{isFilePlainText-CWFPwmnU.js → isFilePlainText-DW_xMer1.js} +1 -1
- package/dist/assets/ja-Bruw3RYC.js +16 -0
- package/dist/assets/ko-idhXGF8j.js +16 -0
- package/dist/assets/main-BkyaQhJX.js +930 -0
- package/dist/assets/{main-DnBAoktx.js → main-CzdPuL0s.js} +2 -2
- package/dist/assets/markdownCore-Cn4Tyejo.js +5 -0
- package/dist/assets/{miniChat-BKyvLseN.js → miniChat-CCkqKOLp.js} +2 -2
- package/dist/assets/{mobile-DKjeFgMN.js → mobile-mvxTo055.js} +2 -2
- package/dist/assets/patchFileDiff-Csk4iNUi.js +13 -0
- package/dist/assets/pl-CGZWUqZJ.js +16 -0
- package/dist/assets/pt-BR-B8V7u7tA.js +16 -0
- package/dist/assets/renderElectronMiniChatApp-BxoyujA5.js +2 -0
- package/dist/assets/renderMobileApp-B48CsP4r.js +25 -0
- package/dist/assets/runtimeEndpointReset-Bg5gx18H.js +17 -0
- package/dist/assets/scheduledTasksApi-BW0Etzwz.js +1 -0
- package/dist/assets/{shikiHighlight-TEQCmzW8.js → shikiHighlight-CRl0SUC9.js} +1 -1
- package/dist/assets/surface-oMvhsog3.js +1 -0
- package/dist/assets/tr-BK_KiO2V.js +7 -0
- package/dist/assets/uk-CG74xV8N.js +16 -0
- package/dist/assets/{useAppFontEffects-D7sNpuL2.js → useAppFontEffects-CPKeOpUo.js} +160 -157
- package/dist/assets/useGitComparison-DH220WmO.js +1 -0
- package/dist/assets/useInlineCommentController-BiWz4AVb.js +2 -0
- package/dist/assets/useRootScrollLock-BS4-kr7P.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-Dp3KMRQG.js → vendor-codemirror-language-data-Bg4AEeCr.js} +2 -2
- package/dist/assets/vendor-zod-BPAANqvH.js +39 -0
- package/dist/assets/{web-components-_mNEMjFY.js → web-components-z7hfnS8n.js} +1 -1
- package/dist/assets/zh-CN-T4AXofZI.js +16 -0
- package/dist/assets/zh-TW-Dte40sxp.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 +2 -3
- package/server/index.d.ts +15 -0
- package/server/index.js +45 -9
- package/server/lib/data-dir-migration.js +40 -0
- package/server/lib/data-dir-migration.test.js +59 -0
- package/server/lib/event-stream/runtime.test.js +12 -12
- package/server/lib/fs/DOCUMENTATION.md +3 -0
- package/server/lib/fs/routes.js +31 -0
- package/server/lib/fs/routes.test.js +110 -1
- package/server/lib/git/DOCUMENTATION.md +29 -5
- package/server/lib/git/routes.js +24 -4
- package/server/lib/git/service.js +184 -198
- package/server/lib/git/service.test.js +422 -25
- package/server/lib/inherited-env.js +16 -9
- package/server/lib/inherited-env.test.js +8 -9
- package/server/lib/message-queue/DOCUMENTATION.md +4 -1
- package/server/lib/network-defaults.js +23 -0
- package/server/lib/network-defaults.test.js +52 -0
- package/server/lib/opencode/DOCUMENTATION.md +17 -1
- package/server/lib/opencode/bootstrap-runtime.js +2 -0
- package/server/lib/opencode/feature-routes-runtime.js +2 -0
- package/server/lib/opencode/openchamber-routes.js +64 -4
- package/server/lib/opencode/openchamber-routes.test.js +128 -1
- package/server/lib/opencode/routes.js +5 -3
- package/server/lib/opencode/settings-files.js +214 -0
- package/server/lib/opencode/settings-helpers.js +85 -41
- package/server/lib/opencode/settings-helpers.test.js +166 -1
- package/server/lib/opencode/settings-registry.json +747 -0
- package/server/lib/opencode/settings-runtime.js +139 -18
- package/server/lib/opencode/settings-runtime.test.js +163 -5
- 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 +31 -1
- 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/index.js +7 -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 +1 -1
- package/server/lib/quota/providers/opencode-go.test.js +26 -1
- package/server/lib/quota/providers/openrouter.js +119 -24
- package/server/lib/quota/providers/openrouter.test.js +330 -0
- package/server/lib/relay/DOCUMENTATION.md +55 -0
- 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/tunnel-codec.js +9 -0
- package/server/lib/relay/tunnel-host.js +66 -17
- package/server/lib/session-assist/runtime.js +6 -10
- package/server/lib/session-goal/runtime.js +4 -9
- package/server/lib/small-model/DOCUMENTATION.md +3 -2
- package/server/lib/small-model/call.js +9 -4
- package/server/lib/small-model/call.test.js +29 -0
- package/server/lib/small-model/index.js +5 -10
- package/server/lib/small-model/runtime-providers.js +15 -7
- package/server/lib/small-model/runtime-providers.test.js +7 -1
- package/server/lib/terminal/DOCUMENTATION.md +4 -4
- package/server/lib/terminal/runtime.js +11 -6
- package/server/lib/terminal/runtime.test.js +20 -13
- package/server/lib/walkthrough/DOCUMENTATION.md +33 -14
- package/server/lib/walkthrough/model-settings.js +8 -12
- package/server/lib/walkthrough/prompt.js +3 -1
- package/server/lib/walkthrough/sources.js +23 -2
- package/dist/assets/DiffView-hDBKesA_.js +0 -4
- package/dist/assets/FilesView-CdWzeOp9.js +0 -17
- package/dist/assets/MarkdownImageGallery-Czv_mhvS.js +0 -1
- package/dist/assets/MarkdownRendererImpl-BrMcswiy.js +0 -12
- package/dist/assets/OnboardingScreen-ChxQoAkx.js +0 -1
- package/dist/assets/PierreDiffViewer-BchBtZlp.js +0 -157
- package/dist/assets/SettingsView-BKf5xBsP.js +0 -28
- package/dist/assets/WalkthroughView-DOeCsMju.js +0 -4
- package/dist/assets/contextPanelEmbeddedChat-C0ZLAepL.js +0 -1
- package/dist/assets/de-B5JaA3SA.js +0 -10
- package/dist/assets/es-EPIH88ii.js +0 -16
- package/dist/assets/fr-CAMfxUdZ.js +0 -14
- package/dist/assets/index-DF5kMP-i.js +0 -39
- package/dist/assets/index-Dl3xbAB_.css +0 -1
- package/dist/assets/ja-CifOE1VN.js +0 -16
- package/dist/assets/ko-BBDyAyYQ.js +0 -16
- package/dist/assets/main-MhSaOZoB.js +0 -930
- package/dist/assets/markdownCore-BrBwOOMn.js +0 -3
- package/dist/assets/patchFileDiff-o8ms1me8.js +0 -10
- package/dist/assets/pl-DgEHYfFM.js +0 -16
- package/dist/assets/pt-BR-DuWHojUw.js +0 -16
- package/dist/assets/renderElectronMiniChatApp-CDgBwwCS.js +0 -2
- package/dist/assets/renderMobileApp-DJIKIK1r.js +0 -25
- package/dist/assets/runtimeEndpointReset-CYJ9O_xX.js +0 -17
- package/dist/assets/scheduledTasksApi-D8yamh1b.js +0 -1
- package/dist/assets/tr-D5m2yIWL.js +0 -7
- package/dist/assets/uk-D_q60oog.js +0 -16
- package/dist/assets/useInlineCommentController-Brz_cLcQ.js +0 -2
- package/dist/assets/useRootScrollLock-lXEnWlaU.js +0 -1
- package/dist/assets/vendor-ghostty-web-CrxinGGH.js +0 -13
- package/dist/assets/vendor-zod-CK21ihK-.js +0 -39
- package/dist/assets/zh-CN-D8QvsVeg.js +0 -16
- package/dist/assets/zh-TW-B9t7rQQO.js +0 -16
|
@@ -34,6 +34,7 @@ export const TunnelFrameType = {
|
|
|
34
34
|
WsClose: 10,
|
|
35
35
|
Ping: 11,
|
|
36
36
|
Pong: 12,
|
|
37
|
+
DeliveryAck: 13,
|
|
37
38
|
};
|
|
38
39
|
|
|
39
40
|
const TUNNEL_FRAME_TYPE_VALUES = new Set(Object.values(TunnelFrameType));
|
|
@@ -43,6 +44,14 @@ export const isTunnelFrameType = (value) => TUNNEL_FRAME_TYPE_VALUES.has(value);
|
|
|
43
44
|
|
|
44
45
|
const MAX_STREAM_ID = 0xffffffff;
|
|
45
46
|
|
|
47
|
+
/** Decode the client's cumulative raw tunnel-frame byte count. */
|
|
48
|
+
export const decodeDeliveryAck = (payload) => {
|
|
49
|
+
if (payload.length !== 8) throw new Error('invalid delivery acknowledgement');
|
|
50
|
+
const bytes = Number(new DataView(payload.buffer, payload.byteOffset, payload.byteLength).getBigUint64(0));
|
|
51
|
+
if (!Number.isSafeInteger(bytes)) throw new Error('invalid delivery acknowledgement');
|
|
52
|
+
return bytes;
|
|
53
|
+
};
|
|
54
|
+
|
|
46
55
|
export class TunnelCodecError extends Error {
|
|
47
56
|
constructor(message) {
|
|
48
57
|
super(message);
|
|
@@ -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';
|
|
@@ -74,6 +73,11 @@ const BODY_BUFFER_MAX_BYTES = 512 * 1024;
|
|
|
74
73
|
// completes, so a stalled tunnel converts into an ambiguous transport failure
|
|
75
74
|
// (which the client already retries) instead of a hung loopback request.
|
|
76
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;
|
|
77
81
|
|
|
78
82
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
79
83
|
|
|
@@ -99,13 +103,17 @@ const isWsClosePayload = (parsed) => Boolean(parsed && typeof parsed === 'object
|
|
|
99
103
|
* sendFrame: (plaintextFrame: Uint8Array) => void | Promise<void>,
|
|
100
104
|
* getBufferedAmount: () => number,
|
|
101
105
|
* bodyDeliveryTimeoutMs?: number,
|
|
106
|
+
* responseChunkBytes?: number,
|
|
107
|
+
* cancelPendingFrames?: (streamId: number) => void,
|
|
102
108
|
* }} deps
|
|
103
109
|
*/
|
|
104
|
-
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 = () => {} }) => {
|
|
105
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 }>} */
|
|
106
112
|
const streams = new Map();
|
|
107
113
|
const assembler = createFragmentAssembler();
|
|
108
114
|
let closed = false;
|
|
115
|
+
let pendingWsBytes = 0;
|
|
116
|
+
let pendingWsMessages = 0;
|
|
109
117
|
|
|
110
118
|
const send = async (frame) => {
|
|
111
119
|
if (closed) return;
|
|
@@ -128,6 +136,7 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
|
|
|
128
136
|
const stream = streams.get(streamId);
|
|
129
137
|
if (!stream) return;
|
|
130
138
|
dropStream(streamId);
|
|
139
|
+
cancelPendingFrames(streamId);
|
|
131
140
|
if (stream.kind === 'http') {
|
|
132
141
|
try {
|
|
133
142
|
stream.body?.error(new Error(String(reason ?? 'aborted')));
|
|
@@ -205,17 +214,27 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
|
|
|
205
214
|
if (STRIPPED_RESPONSE_HEADERS.has(name)) continue;
|
|
206
215
|
responseHeaders[name] = value;
|
|
207
216
|
}
|
|
208
|
-
|
|
217
|
+
if (closed || stream.abort.signal.aborted) {
|
|
218
|
+
await response.body?.cancel();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
209
221
|
|
|
210
222
|
try {
|
|
223
|
+
await sendJson(TunnelFrameType.HttpResponse, streamId, { status: response.status, headers: responseHeaders });
|
|
211
224
|
if (response.body) {
|
|
212
225
|
for await (const chunk of response.body) {
|
|
213
226
|
if (closed || stream.abort.signal.aborted) return;
|
|
214
227
|
const bytes = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
|
|
215
|
-
|
|
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) {
|
|
216
234
|
await waitForBackpressure(stream.abort.signal);
|
|
217
235
|
if (closed || stream.abort.signal.aborted) return;
|
|
218
|
-
await
|
|
236
|
+
await Promise.all(pieces.slice(offset, offset + groupSize).map(piece =>
|
|
237
|
+
send(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, piece))));
|
|
219
238
|
}
|
|
220
239
|
}
|
|
221
240
|
}
|
|
@@ -448,6 +467,7 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
|
|
|
448
467
|
try {
|
|
449
468
|
socket = new WebSocket(url, open.protocols, {
|
|
450
469
|
headers: dialHeaders,
|
|
470
|
+
maxPayload: MAX_PENDING_WS_BYTES,
|
|
451
471
|
});
|
|
452
472
|
} catch (error) {
|
|
453
473
|
void sendAbort(streamId, error?.message ?? 'ws dial failed');
|
|
@@ -455,6 +475,8 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
|
|
|
455
475
|
}
|
|
456
476
|
const stream = { kind: 'ws', socket, opened: false };
|
|
457
477
|
streams.set(streamId, stream);
|
|
478
|
+
let outputChain = Promise.resolve();
|
|
479
|
+
let socketPendingMessages = 0;
|
|
458
480
|
|
|
459
481
|
socket.on('open', () => {
|
|
460
482
|
if (streams.get(streamId) !== stream) return;
|
|
@@ -465,23 +487,47 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
|
|
|
465
487
|
if (streams.get(streamId) !== stream || closed) return;
|
|
466
488
|
const bytes = Buffer.isBuffer(data) ? new Uint8Array(data) : new Uint8Array(Buffer.concat(data));
|
|
467
489
|
const frameType = isBinary ? TunnelFrameType.WsBinary : TunnelFrameType.WsText;
|
|
468
|
-
|
|
469
|
-
|
|
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) {
|
|
470
505
|
await waitForBackpressure(null);
|
|
471
506
|
if (streams.get(streamId) !== stream || closed) return;
|
|
472
|
-
await
|
|
507
|
+
await Promise.all(chunks.slice(offset, offset + groupSize).map((chunk, index) =>
|
|
508
|
+
send(encodeTunnelFrame(frameType, streamId, chunk, offset + index < chunks.length - 1))));
|
|
473
509
|
}
|
|
474
|
-
})()
|
|
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
|
+
});
|
|
475
519
|
});
|
|
476
520
|
socket.on('close', (code, reasonBuffer) => {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
+
});
|
|
485
531
|
});
|
|
486
532
|
socket.on('error', (error) => {
|
|
487
533
|
if (streams.get(streamId) !== stream) return;
|
|
@@ -512,6 +558,7 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
|
|
|
512
558
|
const stream = streams.get(streamId);
|
|
513
559
|
if (!stream || stream.kind !== 'ws') return;
|
|
514
560
|
dropStream(streamId);
|
|
561
|
+
cancelPendingFrames(streamId);
|
|
515
562
|
let close = { code: 1000, reason: '' };
|
|
516
563
|
try {
|
|
517
564
|
close = decodeJsonPayload(payload, isWsClosePayload);
|
|
@@ -520,6 +567,8 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
|
|
|
520
567
|
}
|
|
521
568
|
const code = Number.isInteger(close.code) && close.code >= 1000 && close.code <= 4999 ? close.code : 1000;
|
|
522
569
|
try {
|
|
570
|
+
// A paused receiver still needs to read the peer's close handshake.
|
|
571
|
+
if (CAN_PAUSE_WS_READS) stream.socket.resume();
|
|
523
572
|
stream.socket.close(code, typeof close.reason === 'string' ? close.reason : '');
|
|
524
573
|
} catch {
|
|
525
574
|
stream.socket.terminate();
|
|
@@ -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 AIWORKER_SETTINGS_FILE = path.join(
|
|
16
17
|
process.env.AIWORKER_DATA_DIR
|
|
@@ -23,16 +24,11 @@ const AIWORKER_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: AIWORKER_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;
|
|
@@ -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 AIWORKER_SETTINGS_FILE = path.join(
|
|
24
25
|
process.env.AIWORKER_DATA_DIR
|
|
@@ -27,15 +28,9 @@ const AIWORKER_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: AIWORKER_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.
|
|
@@ -117,8 +117,9 @@ other runtime API.
|
|
|
117
117
|
- Everything else: OpenAI-compatible `/chat/completions` against the
|
|
118
118
|
provider's base URL, resolved from (1) `provider.<id>.options.baseURL`
|
|
119
119
|
in the OpenCode config, (2) the hardcoded `https://api.openai.com/v1`
|
|
120
|
-
endpoint, (3) the endpoint OpenCode resolved at runtime,
|
|
121
|
-
|
|
120
|
+
endpoint, (3) the selected model's endpoint OpenCode resolved at runtime,
|
|
121
|
+
(4) the provider-level runtime endpoint, or (5) the provider's `api` field
|
|
122
|
+
from the models.dev catalog. The credential follows
|
|
122
123
|
the same shape: config `options.apiKey`, then the runtime credential, then
|
|
123
124
|
the auth.json entry. `provider.<id>.options.headers` is sent with the
|
|
124
125
|
request and overrides the bearer default, so gateways that authenticate on
|
|
@@ -606,6 +606,8 @@ const readProviderConfig = (workingDirectory, providerID) => {
|
|
|
606
606
|
}
|
|
607
607
|
}
|
|
608
608
|
|
|
609
|
+
const getRuntimeModel = (runtimeProvider, modelID) => runtimeProvider?.models?.get(modelID) ?? null;
|
|
610
|
+
|
|
609
611
|
// ---------------------------------------------------------------------------
|
|
610
612
|
// Dispatch
|
|
611
613
|
// ---------------------------------------------------------------------------
|
|
@@ -650,6 +652,7 @@ export async function callSmallModel({ auth, catalog, workingDirectory, sessionI
|
|
|
650
652
|
const tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS;
|
|
651
653
|
const providerConfig = readProviderConfig(workingDirectory, providerID);
|
|
652
654
|
const runtimeProvider = await getRuntimeProvider(providerID);
|
|
655
|
+
const runtimeModel = getRuntimeModel(runtimeProvider, modelID);
|
|
653
656
|
// Match OpenCode's resolveSDK precedence: config `provider.<id>.options`
|
|
654
657
|
// wins, then what OpenCode itself resolved at runtime (the only place a
|
|
655
658
|
// plugin's credential exists), and the auth.json entry last.
|
|
@@ -759,9 +762,10 @@ export async function callSmallModel({ auth, catalog, workingDirectory, sessionI
|
|
|
759
762
|
// base URL for that provider (openai itself included). When a custom provider
|
|
760
763
|
// is not in the catalog (e.g. a user-configured OpenAI-compatible proxy),
|
|
761
764
|
// fall back to its baseURL from the OpenCode provider config, then to the
|
|
762
|
-
// endpoint OpenCode resolved at runtime
|
|
763
|
-
//
|
|
764
|
-
//
|
|
765
|
+
// selected model's endpoint OpenCode resolved at runtime, then to the
|
|
766
|
+
// provider-level runtime endpoint. For a plugin provider, the runtime listing
|
|
767
|
+
// is the only place those endpoints exist, and several are local proxies the
|
|
768
|
+
// plugin itself runs. The openai provider also respects
|
|
765
769
|
// provider.openai.options.baseURL — OpenCode itself uses the same config for
|
|
766
770
|
// all providers including openai.
|
|
767
771
|
const provider = getCatalogProvider(catalog, providerID);
|
|
@@ -771,7 +775,8 @@ export async function callSmallModel({ auth, catalog, workingDirectory, sessionI
|
|
|
771
775
|
? providerConfigUrl
|
|
772
776
|
: providerID === 'openai'
|
|
773
777
|
? defaultOpenaiUrl
|
|
774
|
-
:
|
|
778
|
+
: runtimeModel?.api?.url
|
|
779
|
+
?? runtimeProvider?.baseURL
|
|
775
780
|
?? (typeof provider?.api === 'string' && provider.api
|
|
776
781
|
? provider.api
|
|
777
782
|
: null);
|
|
@@ -565,6 +565,35 @@ describe('callSmallModel — custom provider config', () => {
|
|
|
565
565
|
expect(init.headers.Authorization).toBe('Bearer plugin-key');
|
|
566
566
|
});
|
|
567
567
|
|
|
568
|
+
it('uses the selected runtime model endpoint', async () => {
|
|
569
|
+
readConfig.mockReturnValue({});
|
|
570
|
+
getRuntimeProvider.mockResolvedValue({
|
|
571
|
+
id: 'runtime-provider',
|
|
572
|
+
apiKey: 'plugin-key',
|
|
573
|
+
baseURL: 'https://runtime-provider/v1beta',
|
|
574
|
+
models: new Map([
|
|
575
|
+
['first-model', { api: { url: 'https://runtime-provider/v1beta', npm: '@ai-sdk/google' } }],
|
|
576
|
+
['selected-model', { api: { url: 'https://runtime-provider/v1', npm: '@ai-sdk/openai' } }],
|
|
577
|
+
]),
|
|
578
|
+
anonymousZen: false,
|
|
579
|
+
});
|
|
580
|
+
fetchMock.mockResolvedValue(ok('done'));
|
|
581
|
+
|
|
582
|
+
await callSmallModel({
|
|
583
|
+
auth: {},
|
|
584
|
+
catalog: {},
|
|
585
|
+
workingDirectory: '/proj',
|
|
586
|
+
providerID: 'runtime-provider',
|
|
587
|
+
modelID: 'selected-model',
|
|
588
|
+
prompt: 'hi',
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
const { url, init } = lastCall(fetchMock);
|
|
592
|
+
expect(url).toBe('https://runtime-provider/v1/chat/completions');
|
|
593
|
+
expect(url).not.toContain('/v1beta');
|
|
594
|
+
expect(JSON.parse(init.body).model).toBe('selected-model');
|
|
595
|
+
});
|
|
596
|
+
|
|
568
597
|
it('keeps the ChatGPT-plan login on its own transport instead of the runtime key', async () => {
|
|
569
598
|
readConfig.mockReturnValue({});
|
|
570
599
|
// OpenCode reports an OAuth access token as `options.apiKey` for openai;
|
|
@@ -6,6 +6,7 @@ import { readConfigLayers } from '../opencode/shared.js';
|
|
|
6
6
|
import { getModelCatalog } from './catalog.js';
|
|
7
7
|
import { resolveSmallModel, parseModelRef, isUsableAuthEntry, getAuthEntryForProvider } from './resolve.js';
|
|
8
8
|
import { DEDICATED_WIRE_FORMAT_PROVIDERS, callSmallModel, resolveProviderLogin } from './call.js';
|
|
9
|
+
import { readMergedSettingsSync } from '../opencode/settings-files.js';
|
|
9
10
|
import { getRuntimeProviderSnapshot } from './runtime-providers.js';
|
|
10
11
|
|
|
11
12
|
// Never a small model, whatever the transport looks like. A plugin can publish
|
|
@@ -26,16 +27,10 @@ const AIWORKER_SETTINGS_FILE = path.join(
|
|
|
26
27
|
// AiWorker's own settings: when the user unchecks "use default small model"
|
|
27
28
|
// their explicit override outranks every other resolution step.
|
|
28
29
|
const readSmallModelSettingsOverride = () => {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (settings.smallModelUseDefault !== false) return null;
|
|
34
|
-
const override = typeof settings.smallModelOverride === 'string' ? settings.smallModelOverride.trim() : '';
|
|
35
|
-
return override || null;
|
|
36
|
-
} catch {
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
30
|
+
const settings = readMergedSettingsSync({ fs, path, settingsFilePath: AIWORKER_SETTINGS_FILE });
|
|
31
|
+
if (settings.smallModelUseDefault !== false) return null;
|
|
32
|
+
const override = typeof settings.smallModelOverride === 'string' ? settings.smallModelOverride.trim() : '';
|
|
33
|
+
return override || null;
|
|
39
34
|
};
|
|
40
35
|
|
|
41
36
|
// Rough safety clamp so a huge input never blows the model's context window.
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
//
|
|
9
9
|
// `GET /provider` is where that state becomes visible. It reports, per
|
|
10
10
|
// provider, the resolved `options.baseURL` and `options.apiKey`, and per model
|
|
11
|
-
// the wire adapter (`api.npm`) and endpoint (`api.url`).
|
|
11
|
+
// the wire adapter (`api.npm`) and endpoint (`api.url`). The provider-level
|
|
12
|
+
// endpoint remains only as a fallback when the selected model has no endpoint.
|
|
12
13
|
//
|
|
13
14
|
// What it does NOT report is `options.fetch`. OpenCode strips functions from
|
|
14
15
|
// the response, and a plugin is free to put its whole protocol in there:
|
|
@@ -61,8 +62,8 @@ export function resetOpenCodeRuntimeProviders() {
|
|
|
61
62
|
/**
|
|
62
63
|
* The boundary. Everything the `/provider` payload claims is checked here, so
|
|
63
64
|
* the rest of this module and its callers work with settled values:
|
|
64
|
-
* a credential we may use,
|
|
65
|
-
* anonymous zen case.
|
|
65
|
+
* a credential we may use, provider and model endpoints, and whether the
|
|
66
|
+
* provider is the anonymous zen case.
|
|
66
67
|
*
|
|
67
68
|
* The credential deliberately prefers `options.apiKey` over the `key` field:
|
|
68
69
|
* for a plugin provider the former is what its auth loader produced and what
|
|
@@ -82,13 +83,22 @@ function parseProviderListing(payload) {
|
|
|
82
83
|
const id = text(record(raw).id);
|
|
83
84
|
if (!id) continue;
|
|
84
85
|
const options = record(record(raw).options);
|
|
85
|
-
const
|
|
86
|
+
const models = new Map();
|
|
87
|
+
for (const [modelID, rawModel] of Object.entries(record(record(raw).models))) {
|
|
88
|
+
const model = record(rawModel);
|
|
89
|
+
const api = record(model.api);
|
|
90
|
+
const modelURL = endpoint(api.url);
|
|
91
|
+
const modelNpm = text(api.npm);
|
|
92
|
+
models.set(modelID, { api: { url: modelURL, npm: modelNpm } });
|
|
93
|
+
}
|
|
94
|
+
const firstModel = models.values().next().value;
|
|
86
95
|
const declaredKey = text(options.apiKey);
|
|
87
96
|
providers.set(id, {
|
|
88
97
|
id,
|
|
89
98
|
source: text(record(raw).source),
|
|
90
99
|
apiKey: declaredKey === ZEN_ANONYMOUS_API_KEY ? null : (declaredKey ?? text(record(raw).key)),
|
|
91
|
-
baseURL: endpoint(options.baseURL) ??
|
|
100
|
+
baseURL: endpoint(options.baseURL) ?? firstModel?.api?.url ?? null,
|
|
101
|
+
models,
|
|
92
102
|
// True only for the zen-without-login case: a provider that is present
|
|
93
103
|
// and usable through OpenCode, but that we must not call ourselves.
|
|
94
104
|
anonymousZen: declaredKey === ZEN_ANONYMOUS_API_KEY,
|
|
@@ -152,5 +162,3 @@ export async function getRuntimeProvider(providerID) {
|
|
|
152
162
|
const current = await getRuntimeProviderSnapshot();
|
|
153
163
|
return current?.providers.get(providerID) ?? null;
|
|
154
164
|
}
|
|
155
|
-
|
|
156
|
-
|
|
@@ -14,7 +14,10 @@ const providerPayload = (overrides = {}) => ({
|
|
|
14
14
|
id: 'llmapi',
|
|
15
15
|
source: 'config',
|
|
16
16
|
options: { apiKey: 'plugin-key', baseURL: 'https://api.llmapi.ai/v1/' },
|
|
17
|
-
models: {
|
|
17
|
+
models: {
|
|
18
|
+
'claude-opus-4-8': { api: { id: 'claude-opus-4-8', url: '', npm: '@ai-sdk/anthropic' } },
|
|
19
|
+
'gpt-5.6-luna': { api: { id: 'gpt-5.6-luna', url: 'https://api.llmapi.ai/v1', npm: '@ai-sdk/openai' } },
|
|
20
|
+
},
|
|
18
21
|
},
|
|
19
22
|
{
|
|
20
23
|
id: 'opencode',
|
|
@@ -59,6 +62,9 @@ describe('OpenCode runtime provider snapshot', () => {
|
|
|
59
62
|
const provider = await getRuntimeProvider('llmapi');
|
|
60
63
|
|
|
61
64
|
expect(provider).toMatchObject({ apiKey: 'plugin-key', baseURL: 'https://api.llmapi.ai/v1' });
|
|
65
|
+
expect(provider.models.get('gpt-5.6-luna')).toEqual({
|
|
66
|
+
api: { url: 'https://api.llmapi.ai/v1', npm: '@ai-sdk/openai' },
|
|
67
|
+
});
|
|
62
68
|
expect(fetchMock.mock.calls[0][0]).toBe('http://127.0.0.1:4096/provider');
|
|
63
69
|
expect(fetchMock.mock.calls[0][1].headers).toMatchObject({ Authorization: 'Basic test' });
|
|
64
70
|
});
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
`/api/terminal/ws` is the only terminal data transport. It uses v3 binary JSON control frames and is opened through `openRuntimeWebSocket`, preserving direct, Electron proxy, URL-token authentication, and private-relay routing.
|
|
10
10
|
|
|
11
11
|
- `attach` registers a connection for one terminal. One socket may attach to many terminals.
|
|
12
|
-
- Every attach and reconnect begins with an authoritative `snapshot` containing bounded history and the
|
|
12
|
+
- Every attach and reconnect begins with an authoritative `snapshot` containing bounded history, the current sequence, and the PTY `cols`/`rows` the history was drawn for. The client replays the history at that size before fitting its viewport; replaying shell output at another width leaves stray fragments that the shell's own SIGWINCH redraw never clears.
|
|
13
13
|
- A current socket that closes or errors before its initial `open` invalidates its URL-scoped auth token before retrying, so retries mint a fresh token instead of backing off against a rejected upgrade. Hidden or offline clients wait 60 seconds and wake promptly on visibility/online recovery.
|
|
14
14
|
- `output`, `exit`, and `restarted` carry monotonically increasing per-terminal sequences. Output carries raw live bytes plus replay-safe bytes with terminal query exchanges removed.
|
|
15
15
|
- Attach registers before capturing the snapshot, buffers concurrent events, drops events represented by the snapshot sequence, then enters live delivery.
|
|
@@ -28,14 +28,14 @@ HTTP remains the authenticated command plane for create, resize, appearance upda
|
|
|
28
28
|
- Concurrent creates for one ID are single-flight only when working directory, shell preference, login mode, launch mode, and session purpose match. Command-mode creates must also match the command text, unless the purpose is a project action that is already running for the same resolved `(cwd, actionId)` pair. In that case the runtime returns the existing session and its existing execution identity, even when another client requested a different session ID. Existing IDs cannot be reused for another working directory or another purpose.
|
|
29
29
|
- Dimensions are bounded to 1-1000 columns and 1-500 rows; input is capped at 64 KiB.
|
|
30
30
|
- A client may create before its renderer has mounted. It derives an initial size from the container and font metrics (falling back to 80x24 when unavailable), then sends a resize once Ghostty reports its final dimensions. This allows shell startup and renderer initialization to overlap.
|
|
31
|
-
- PTY children
|
|
32
|
-
- PTY children also strip AppImage `ARGV0` (and other host-private shell vars such as `ELECTRON_RUN_AS_NODE`, `BASH_ENV`, `ENV`, `BASH_XTRACEFD`). An exported `ARGV0` makes zsh rewrite argv[0] for every external command, which breaks Python venv detection and other argv[0]/$0 consumers while leaving `/proc/self/exe` correct. On
|
|
31
|
+
- PTY children never inherit `NODE_CHANNEL_FD`; daemon IPC descriptors are host-private and invalid after PTY descriptor cleanup, and an inherited value (even empty) makes Node CLIs such as opencode print `Failed to parse IPC channel number` on exit.
|
|
32
|
+
- PTY children also strip AppImage `ARGV0` (and other host-private shell vars such as `ELECTRON_RUN_AS_NODE`, `BASH_ENV`, `ENV`, `BASH_XTRACEFD`). An exported `ARGV0` makes zsh rewrite argv[0] for every external command, which breaks Python venv detection and other argv[0]/$0 consumers while leaving `/proc/self/exe` correct. On POSIX, PTY spawn is wrapped with `env -u ARGV0 -u NODE_CHANNEL_FD` (`resolvePosixPtyLaunch`) because `bun-pty` merges the native OS environ and would otherwise reintroduce both after a JS-only delete.
|
|
33
33
|
- `GET /api/terminal/shells` reports shell IDs available on the active server using the same augmented PATH provided to spawned PTYs, plus whether each executable has a supported login-mode argument. `auto` preserves environment/platform fallback order; an explicit unavailable shell fails creation instead of silently running a different shell. Login mode is opt-in and uses only built-in arguments for known shells. Interactive shells still launch as before. Command-mode launches reuse the same environment and login support, but switch argv by shell family: POSIX and Fish use interactive `-c`, Nushell uses `-c`, PowerShell uses `-Command`, and cmd uses `/d /s /c`. Preference changes affect new sessions and explicit restarts, not running PTYs.
|
|
34
34
|
- PTY data and exit callbacks enter one FIFO queue. The runtime wires those listeners in the same synchronous turn that receives the PTY object. `node-pty` and `bun-pty` both expose the PTY before dispatching registered callbacks. If a backend emitted exit before listener registration, this layer could not recover it, so the wiring stays adjacent to PTY creation.
|
|
35
35
|
- Scrollback is retained on the server and capped at 512 KiB with UTF-8-safe trimming. Device-status, device-attribute, cursor-position reply, and color-query exchanges are removed from replay history with incomplete control sequences carried across PTY chunks; live output remains byte-for-byte unchanged.
|
|
36
36
|
- Exited sessions remain attachable until explicit close, idle cleanup, or a successful replacement of the same project action. Creating a replacement retires only exited records for the same resolved directory and action, after the new PTY starts. Failed creation preserves the old record and output. These replaced records do not exhaust the terminal capacity limit.
|
|
37
37
|
- Deduplicated create responses may describe another client's execution. Cancellation cleanup closes only the terminal ID allocated for the cancelled request; it never closes an adopted peer execution.
|
|
38
|
-
- Create and restart validate the working directory with a real `stat` and answer HTTP 400 `Invalid working directory` when it is not a directory. When the path does not exist at all (`ENOENT`/`ENOTDIR`, a worktree deleted outside AiWorker) the body also carries `code: "TERMINAL_CWD_MISSING"`.
|
|
38
|
+
- Create and restart validate the working directory with a real `stat` and answer HTTP 400 `Invalid working directory` when it is not a directory. When the path does not exist at all (`ENOENT`/`ENOTDIR`, a worktree deleted outside AiWorker) the body also carries `code: "TERMINAL_CWD_MISSING"`. The client shows the failure without moving the session. The runtime never substitutes a parent directory on its own.
|
|
39
39
|
- Restarts are serialized per terminal. Each restart spawns and wires the replacement before terminating the old process, retaining the terminal ID. Command-mode sessions reject restart with HTTP 400 instead of silently turning into interactive shells with stale action metadata.
|
|
40
40
|
- A delete that arrives while create is still pending leaves a cancellation tombstone. When the PTY arrives, the runtime terminates it immediately, never inserts the session into the live map, and returns a create error while the delete still succeeds.
|
|
41
41
|
- Close uses SIGTERM with bounded SIGKILL escalation. Force-kill, idle cleanup, and runtime shutdown terminate process groups immediately where supported. Removal explicitly sends a fatal scoped closure and evicts client projections even when a PTY backend fails to emit `onExit`; attached terminals are not considered idle.
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
import { sanitizeTerminalHistoryChunk } from './history.js';
|
|
11
11
|
import { consumeTerminalThemeQueries, terminalThemeModeReport } from './theme-response.js';
|
|
12
12
|
import { buildTerminalShellLaunch, createTerminalShellResolver, normalizeTerminalShell } from './shells.js';
|
|
13
|
-
import { stripAppImageArgv0Leak,
|
|
13
|
+
import { stripAppImageArgv0Leak, resolvePosixPtyLaunch } from '../inherited-env.js';
|
|
14
14
|
|
|
15
15
|
const MAX_SESSIONS = 20;
|
|
16
16
|
const MAX_HISTORY_BYTES = 512 * 1024;
|
|
@@ -123,15 +123,16 @@ export function createTerminalRuntime({
|
|
|
123
123
|
for (const executable of resolvedShell.executables) {
|
|
124
124
|
try {
|
|
125
125
|
const env = { ...process.env, PATH: buildAugmentedPath(), TERM: 'xterm-256color', COLORTERM: 'truecolor', COLORFGBG: themeMode === 'light' ? '0;15' : '15;0' };
|
|
126
|
-
// The daemon's IPC fd is closed inside the PTY
|
|
127
|
-
//
|
|
128
|
-
env.NODE_CHANNEL_FD
|
|
126
|
+
// The daemon's IPC fd is closed inside the PTY; an inherited NODE_CHANNEL_FD
|
|
127
|
+
// (even an empty one) makes Node CLIs warn about an unparsable IPC channel.
|
|
128
|
+
delete env.NODE_CHANNEL_FD;
|
|
129
129
|
delete env.BASH_XTRACEFD; delete env.BASH_ENV; delete env.ENV; delete env.ELECTRON_RUN_AS_NODE;
|
|
130
130
|
// AppImage exports ARGV0; zsh would otherwise rewrite argv[0] for every command (#2588).
|
|
131
|
-
// bun-pty also merges the native OS environ, so wrap with `env -u ARGV0` on Linux.
|
|
132
131
|
stripAppImageArgv0Leak(env);
|
|
133
132
|
const shellLaunch = buildTerminalShellLaunch(executable, { mode, command, loginShell });
|
|
134
|
-
|
|
133
|
+
// bun-pty merges the native OS environ back in, so the POSIX launch is
|
|
134
|
+
// wrapped with `env -u` for the variables deleted above.
|
|
135
|
+
const launch = resolvePosixPtyLaunch(shellLaunch.executable, shellLaunch.args);
|
|
135
136
|
const options = { name: 'xterm-256color', cwd, cols, rows, env };
|
|
136
137
|
if (process.platform === 'win32') options.useConpty = true;
|
|
137
138
|
return { process: await provider.spawn(launch.executable, launch.args, options), backend: provider.backend, shell: resolvedShell.id, loginShell };
|
|
@@ -184,6 +185,10 @@ export function createTerminalRuntime({
|
|
|
184
185
|
|
|
185
186
|
const snapshot = (session) => ({
|
|
186
187
|
t: 'snapshot', v: 3, s: session.id, q: session.sequence, history: session.history,
|
|
188
|
+
// The PTY size the history was drawn for: a client replays history at this
|
|
189
|
+
// size before fitting its own viewport, so shell output wrapped for one
|
|
190
|
+
// width never gets re-laid-out at another.
|
|
191
|
+
cols: session.cols, rows: session.rows,
|
|
187
192
|
status: session.status, exitCode: session.exitCode, signal: session.signal,
|
|
188
193
|
mode: session.mode ?? INTERACTIVE_TERMINAL_MODE, purpose: getSessionPurpose(session),
|
|
189
194
|
runtime, ptyBackend: session.backend,
|
|
@@ -319,12 +319,12 @@ describe('terminal runtime', () => {
|
|
|
319
319
|
expect(response.body).toEqual({ sessionId: 'term-1', cols: 120, rows: 40, status: 'running', mode: 'interactive', purpose: { type: 'terminal' } });
|
|
320
320
|
expect(harness.processes[0].options.cwd).toBe('/repo');
|
|
321
321
|
expect(harness.processes[0].options.env.COLORFGBG).toBe('0;15');
|
|
322
|
-
expect(harness.processes[0].options.env
|
|
322
|
+
expect(harness.processes[0].options.env).not.toHaveProperty('NODE_CHANNEL_FD');
|
|
323
323
|
expect(harness.processes[0].options.env).not.toHaveProperty('ARGV0');
|
|
324
324
|
expect(harness.processes[0].options.env).not.toHaveProperty('ELECTRON_RUN_AS_NODE');
|
|
325
|
-
if (process.platform
|
|
325
|
+
if (process.platform !== 'win32') {
|
|
326
326
|
expect(harness.processes[0].shell).toMatch(/\/env$/);
|
|
327
|
-
expect(harness.processes[0].args.slice(0,
|
|
327
|
+
expect(harness.processes[0].args.slice(0, 5)).toEqual(['-u', 'ARGV0', '-u', 'NODE_CHANNEL_FD', expect.any(String)]);
|
|
328
328
|
}
|
|
329
329
|
harness.processes[0].emitData('\u001b[?2031h\u001b]10;?\u0007\u001b]11;?\u0007\u001b[0c');
|
|
330
330
|
expect(harness.processes[0].writes).toEqual(['\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\', '\u001b]11;rgb:fafa/f8f8/f0f0\u001b\\', '\u001b[?1;2c']);
|
|
@@ -380,7 +380,7 @@ describe('terminal runtime', () => {
|
|
|
380
380
|
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-argv0', cwd: '/repo', cols: 80, rows: 24 } }, response);
|
|
381
381
|
expect(response.statusCode).toBe(200);
|
|
382
382
|
expect(harness.processes[0].options.env).not.toHaveProperty('ARGV0');
|
|
383
|
-
if (process.platform
|
|
383
|
+
if (process.platform !== 'win32') {
|
|
384
384
|
expect(harness.processes[0].shell).toMatch(/\/env$/);
|
|
385
385
|
expect(harness.processes[0].args[0]).toBe('-u');
|
|
386
386
|
expect(harness.processes[0].args[1]).toBe('ARGV0');
|
|
@@ -417,9 +417,9 @@ describe('terminal runtime', () => {
|
|
|
417
417
|
const created = createResponse();
|
|
418
418
|
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-shell', cwd: '/repo', shell: 'zsh', loginShell: true } }, created);
|
|
419
419
|
expect(created.statusCode).toBe(200);
|
|
420
|
-
if (process.platform
|
|
420
|
+
if (process.platform !== 'win32') {
|
|
421
421
|
expect(harness.processes[0].shell).toMatch(/\/env$/);
|
|
422
|
-
expect(harness.processes[0].args).toEqual(['-u', 'ARGV0', '/bin/zsh', '-l']);
|
|
422
|
+
expect(harness.processes[0].args).toEqual(['-u', 'ARGV0', '-u', 'NODE_CHANNEL_FD', '/bin/zsh', '-l']);
|
|
423
423
|
} else {
|
|
424
424
|
expect(harness.processes[0].shell).toBe('/bin/zsh');
|
|
425
425
|
expect(harness.processes[0].args).toEqual(['-l']);
|
|
@@ -428,9 +428,9 @@ describe('terminal runtime', () => {
|
|
|
428
428
|
const restarted = createResponse();
|
|
429
429
|
await harness.routes.post.get('/api/terminal/:sessionId/restart')({ params: { sessionId: 'term-shell' }, body: { shell: 'bash', loginShell: true } }, restarted);
|
|
430
430
|
expect(restarted.statusCode).toBe(200);
|
|
431
|
-
if (process.platform
|
|
431
|
+
if (process.platform !== 'win32') {
|
|
432
432
|
expect(harness.processes[1].shell).toMatch(/\/env$/);
|
|
433
|
-
expect(harness.processes[1].args).toEqual(['-u', 'ARGV0', '/bin/bash', '-l']);
|
|
433
|
+
expect(harness.processes[1].args).toEqual(['-u', 'ARGV0', '-u', 'NODE_CHANNEL_FD', '/bin/bash', '-l']);
|
|
434
434
|
} else {
|
|
435
435
|
expect(harness.processes[1].shell).toBe('/bin/bash');
|
|
436
436
|
expect(harness.processes[1].args).toEqual(['-l']);
|
|
@@ -682,8 +682,8 @@ describe('terminal runtime', () => {
|
|
|
682
682
|
sockets.push(first.socket);
|
|
683
683
|
first.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-live' }));
|
|
684
684
|
first.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-second' }));
|
|
685
|
-
expect(await first.next('snapshot', 'term-live')).toMatchObject({ s: 'term-live', q: 0, history: '', status: 'running' });
|
|
686
|
-
expect(await first.next('snapshot', 'term-second')).toMatchObject({ s: 'term-second', q: 0, history: '', status: 'running' });
|
|
685
|
+
expect(await first.next('snapshot', 'term-live')).toMatchObject({ s: 'term-live', q: 0, history: '', status: 'running', cols: 80, rows: 24 });
|
|
686
|
+
expect(await first.next('snapshot', 'term-second')).toMatchObject({ s: 'term-second', q: 0, history: '', status: 'running', cols: 80, rows: 24 });
|
|
687
687
|
first.socket.send(createTerminalWsControlFrame({ t: 'write', v: 3, s: 'term-live', d: 'echo ok\r' }));
|
|
688
688
|
first.socket.send(createTerminalWsControlFrame({ t: 'write', v: 3, s: 'term-second', d: 'pwd\r' }));
|
|
689
689
|
first.socket.send(createTerminalWsControlFrame({ t: 'write', v: 3, s: 'term-live', d: 'echo next\r' }));
|
|
@@ -707,10 +707,17 @@ describe('terminal runtime', () => {
|
|
|
707
707
|
expect(secondClosed.status).toBe(200);
|
|
708
708
|
first.socket.close();
|
|
709
709
|
|
|
710
|
+
// A reconnecting client replays history at the size the PTY currently has.
|
|
711
|
+
const resized = await fetch(`${base}/api/terminal/term-live/resize`, {
|
|
712
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
713
|
+
body: JSON.stringify({ cols: 120, rows: 40 }),
|
|
714
|
+
});
|
|
715
|
+
expect(resized.status).toBe(200);
|
|
716
|
+
|
|
710
717
|
const second = await openTerminalSocket(socketUrl);
|
|
711
718
|
sockets.push(second.socket);
|
|
712
719
|
second.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-live' }));
|
|
713
|
-
expect(await second.next('snapshot')).toMatchObject({ s: 'term-live', q: 2, history: 'ok\r\n', status: 'running' });
|
|
720
|
+
expect(await second.next('snapshot')).toMatchObject({ s: 'term-live', q: 2, history: 'ok\r\n', status: 'running', cols: 120, rows: 40 });
|
|
714
721
|
processes[0].emitExit(7);
|
|
715
722
|
expect(await second.next('exit')).toMatchObject({ s: 'term-live', q: 3, exitCode: 7 });
|
|
716
723
|
|
|
@@ -750,9 +757,9 @@ describe('terminal runtime', () => {
|
|
|
750
757
|
|
|
751
758
|
expect(response.statusCode).toBe(200);
|
|
752
759
|
expect(response.body).toEqual({ sessionId: 'term-command', cols: 80, rows: 24, status: 'running', mode: 'command', purpose: { type: 'terminal' } });
|
|
753
|
-
if (process.platform
|
|
760
|
+
if (process.platform !== 'win32') {
|
|
754
761
|
expect(harness.processes[0].shell).toMatch(/\/env$/);
|
|
755
|
-
expect(harness.processes[0].args).toEqual(['-u', 'ARGV0', '/bin/bash', '-l', '-i', '-c', 'printf ready']);
|
|
762
|
+
expect(harness.processes[0].args).toEqual(['-u', 'ARGV0', '-u', 'NODE_CHANNEL_FD', '/bin/bash', '-l', '-i', '-c', 'printf ready']);
|
|
756
763
|
} else {
|
|
757
764
|
expect(harness.processes[0].args).toEqual(['-l', '-i', '-c', 'printf ready']);
|
|
758
765
|
}
|