@aiworker/web 1.22.2 → 1.23.1
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-teB3HmBT.js} +1 -1
- package/dist/assets/DiffView-DC9RX3MF.js +4 -0
- package/dist/assets/{DirtyBranchSwitchDialog-C22HdWj4.js → DirtyBranchSwitchDialog-DwPkzXsS.js} +1 -1
- package/dist/assets/FilesView-BFZkuu4O.js +13 -0
- package/dist/assets/{GitView-VQQkYuUw.js → GitView-B_x52MHY.js} +4 -4
- package/dist/assets/{JsonTreeView-6lrhwD3q.js → JsonTreeView-cmAzUTiw.js} +1 -1
- package/dist/assets/{LinearIssuesView-Ba6Q9569.js → LinearIssuesView-Dehh5afm.js} +1 -1
- package/dist/assets/MarkdownImageGallery-DgNOqqhE.js +1 -0
- package/dist/assets/MarkdownRendererImpl-DusmMZ_p.js +12 -0
- package/dist/assets/OnboardingScreen-B8fm1H4m.js +1 -0
- package/dist/assets/{PatchDiff-BzcZPlOq.js → PatchDiff-BVr7gJU0.js} +1 -1
- package/dist/assets/PierreDiffViewer-DzLt-nel.js +161 -0
- package/dist/assets/PlanView-BmQUYT_z.js +5 -0
- package/dist/assets/SettingsView-ChWmAcZT.js +28 -0
- package/dist/assets/{SettingsWindow-DDQYaKWQ.js → SettingsWindow-Dy3M6ZB9.js} +1 -1
- package/dist/assets/SymbolsNerdFontMono-Regular-aK5vsLov.woff2 +0 -0
- package/dist/assets/{ToolOutputDialog-iYF8Y2bz.js → ToolOutputDialog-cTl_eLIH.js} +5 -5
- package/dist/assets/{ToolPartDiffPreview-CdjbOVMM.js → ToolPartDiffPreview-C_3iYNJQ.js} +1 -1
- package/dist/assets/{VirtualizedFileDiff-fFFm2QgH.js → VirtualizedFileDiff-CAWthkoy.js} +4 -4
- package/dist/assets/WalkthroughView-C7AHOpCA.js +4 -0
- package/dist/assets/{computeVirtualFileMetrics-CzgLum_K.js → computeVirtualFileMetrics-BkiDSlTW.js} +2 -2
- package/dist/assets/contextPanelEmbeddedChat-BdA6bdky.js +1 -0
- package/dist/assets/de-DJsQr-p7.js +10 -0
- package/dist/assets/es-BuYMlA9g.js +16 -0
- package/dist/assets/fr-ePDay33C.js +14 -0
- package/dist/assets/ghostty-vt-DdA0Zryv.wasm +0 -0
- package/dist/assets/index-BD8ILbxF.js +40 -0
- package/dist/assets/{index-Bl3LSbjn.js → index-n6O3hklM.js} +1 -1
- package/dist/assets/index-zhgg-SDz.css +1 -0
- package/dist/assets/{isDiffPlainText-Dx-dB_8l.js → isDiffPlainText-GMdPz-71.js} +1 -1
- package/dist/assets/{isFilePlainText-CWFPwmnU.js → isFilePlainText-BVuH1zH9.js} +1 -1
- package/dist/assets/ja-BzR9xOY_.js +16 -0
- package/dist/assets/ko-BEjHAm14.js +16 -0
- package/dist/assets/main-BydQ1tr_.js +930 -0
- package/dist/assets/{main-DnBAoktx.js → main-D3ZDQglc.js} +2 -2
- package/dist/assets/markdownCore-Cb2MdZnV.js +5 -0
- package/dist/assets/{miniChat-BKyvLseN.js → miniChat-d01k801U.js} +2 -2
- package/dist/assets/{mobile-DKjeFgMN.js → mobile-D7VV-Ufz.js} +2 -2
- package/dist/assets/patchFileDiff-CdztVMz0.js +13 -0
- package/dist/assets/pl-rQmpxNIF.js +16 -0
- package/dist/assets/pt-BR-CorbsSW-.js +16 -0
- package/dist/assets/renderElectronMiniChatApp-BGG9UIlh.js +2 -0
- package/dist/assets/renderMobileApp-BRP3J1iv.js +25 -0
- package/dist/assets/runtimeEndpointReset-BhT_-c52.js +41 -0
- package/dist/assets/scheduledTasksApi-BEuUF_cj.js +1 -0
- package/dist/assets/{shikiHighlight-TEQCmzW8.js → shikiHighlight-BzXUjlby.js} +1 -1
- package/dist/assets/surface-COKb77Ua.js +1 -0
- package/dist/assets/tr-CoAMXPtI.js +7 -0
- package/dist/assets/uk-YlAX0rRU.js +16 -0
- package/dist/assets/{useAppFontEffects-D7sNpuL2.js → useAppFontEffects-Cwyhjfl5.js} +141 -140
- package/dist/assets/useGitComparison-BCZVefdE.js +1 -0
- package/dist/assets/useInlineCommentController-DDkpBEU8.js +2 -0
- package/dist/assets/useRootScrollLock-CwjoWuC4.js +1 -0
- package/dist/assets/{vendor-base-ui-BeMD8X_w.js → vendor-base-ui-BY_5qla_.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-data-Dp3KMRQG.js → vendor-codemirror-language-data-Cb1YnICj.js} +2 -2
- package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
- package/dist/assets/vendor-zod-B6551dwG.js +39 -0
- package/dist/assets/{web-components-_mNEMjFY.js → web-components-LV3vjTxy.js} +1 -1
- package/dist/assets/zh-CN-DDYg0Jl7.js +16 -0
- package/dist/assets/zh-TW-CQKaCayr.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 +48 -22
- package/server/lib/agent-memory/runtime.js +7 -2
- package/server/lib/agent-memory/runtime.test.js +22 -0
- package/server/lib/data-dir-migration.js +40 -0
- package/server/lib/data-dir-migration.test.js +59 -0
- package/server/lib/event-stream/DOCUMENTATION.md +3 -0
- package/server/lib/event-stream/global-hub.js +29 -4
- package/server/lib/event-stream/global-hub.test.js +22 -0
- package/server/lib/event-stream/global-ws-bridge.js +15 -18
- package/server/lib/event-stream/protocol.js +21 -7
- package/server/lib/event-stream/runtime.js +9 -6
- package/server/lib/event-stream/runtime.test.js +72 -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 +39 -6
- package/server/lib/git/routes.js +45 -12
- package/server/lib/git/routes.test.js +78 -0
- package/server/lib/git/service.js +343 -202
- package/server/lib/git/service.test.js +496 -25
- package/server/lib/github/DOCUMENTATION.md +5 -0
- package/server/lib/github/routes.js +2 -2
- package/server/lib/inherited-env.js +16 -9
- package/server/lib/inherited-env.test.js +8 -9
- package/server/lib/message-queue/DOCUMENTATION.md +11 -1
- package/server/lib/message-queue/runtime.js +13 -0
- package/server/lib/message-queue/runtime.test.js +31 -1
- package/server/lib/network-defaults.js +23 -0
- package/server/lib/network-defaults.test.js +52 -0
- package/server/lib/notifications/DOCUMENTATION.md +1 -1
- package/server/lib/notifications/emitter-runtime.js +4 -3
- package/server/lib/opencode/DOCUMENTATION.md +35 -5
- package/server/lib/opencode/bootstrap-runtime.js +2 -0
- package/server/lib/opencode/env-runtime.js +5 -1
- package/server/lib/opencode/env-runtime.test.js +72 -0
- package/server/lib/opencode/feature-routes-runtime.js +17 -1
- package/server/lib/opencode/lifecycle.js +4 -3
- package/server/lib/opencode/lifecycle.test.js +29 -0
- package/server/lib/opencode/managed-plugin-config.js +1 -1
- package/server/lib/opencode/openchamber-routes.js +64 -4
- package/server/lib/opencode/openchamber-routes.test.js +128 -1
- package/server/lib/opencode/proxy.js +33 -0
- package/server/lib/opencode/routes.js +5 -3
- package/server/lib/opencode/settings-files.js +214 -0
- package/server/lib/opencode/settings-helpers.js +88 -41
- package/server/lib/opencode/settings-helpers.test.js +176 -1
- package/server/lib/opencode/settings-registry.json +750 -0
- package/server/lib/opencode/settings-runtime.js +175 -27
- package/server/lib/opencode/settings-runtime.test.js +321 -7
- package/server/lib/project-context/DOCUMENTATION.md +38 -4
- 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 +256 -25
- package/server/lib/project-context/runtime.test.js +160 -3
- package/server/lib/projects/DOCUMENTATION.md +77 -0
- package/server/lib/projects/project-config.js +179 -17
- package/server/lib/projects/project-config.test.js +97 -0
- package/server/lib/projects/project-id.js +43 -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 +75 -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 +44 -8
- package/server/lib/relay/host-client.test.js +14 -0
- package/server/lib/relay/tunnel-codec.js +9 -0
- package/server/lib/relay/tunnel-host.js +66 -17
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +9 -1
- package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +28 -0
- package/server/lib/scheduled-tasks/runtime.js +12 -2
- package/server/lib/scheduled-tasks/runtime.test.js +53 -0
- package/server/lib/session-assist/DOCUMENTATION.md +131 -66
- package/server/lib/session-assist/context.js +127 -0
- package/server/lib/session-assist/context.test.js +132 -0
- package/server/lib/session-assist/prompt.js +54 -0
- package/server/lib/session-assist/runtime.js +117 -267
- package/server/lib/session-assist/runtime.test.js +203 -0
- 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 +49 -20
- package/server/lib/walkthrough/model-settings.js +8 -12
- package/server/lib/walkthrough/pr-source.test.js +21 -0
- package/server/lib/walkthrough/prompt.js +3 -1
- package/server/lib/walkthrough/pull-request.js +7 -4
- package/server/lib/walkthrough/pull-request.test.js +16 -0
- package/server/lib/walkthrough/routes.js +19 -0
- package/server/lib/walkthrough/routes.test.js +26 -0
- package/server/lib/walkthrough/sources.js +36 -4
- package/server/opencode-worktree-readiness.test.js +130 -0
- 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/PlanView-DyDt_nZ_.js +0 -5
- 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-legendapp-list-gg7YLRW7.js +0 -1
- 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
- package/server/lib/mcp-reconnect/DOCUMENTATION.md +0 -55
- package/server/lib/mcp-reconnect/runtime.js +0 -122
- package/server/lib/mcp-reconnect/runtime.test.js +0 -140
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { createSessionAssistRuntime } from './runtime.js';
|
|
4
|
+
|
|
5
|
+
const resources = [];
|
|
6
|
+
const message = (id, role, text, extra = {}) => ({
|
|
7
|
+
info: { id, role, parentID: 'user', finish: 'stop', time: { completed: 1 }, providerID: 'test-provider', modelID: 'test-model', ...extra },
|
|
8
|
+
parts: [{ type: 'text', text }],
|
|
9
|
+
});
|
|
10
|
+
const output = (recap = 'Зміни готові', suggestion = '') => ({ text: JSON.stringify({ recap, suggestion }), providerID: 'test-provider', modelID: 'test-model' });
|
|
11
|
+
const pause = () => new Promise((resolve) => setTimeout(resolve, 15));
|
|
12
|
+
|
|
13
|
+
async function fixture(generate = async () => output()) {
|
|
14
|
+
const state = {
|
|
15
|
+
messages: [message('user', 'user', 'Виправ помилку'), message('answer', 'assistant', 'Виправлено')],
|
|
16
|
+
session: { id: 'session', directory: '/project', time: {}, metadata: { external: 'keep', aiworker: { note: 'keep' } } },
|
|
17
|
+
targets: { recap: true, suggestion: true },
|
|
18
|
+
gets: 0, failFresh: false, patches: [], requests: [], calls: [],
|
|
19
|
+
};
|
|
20
|
+
const server = createServer(async (request, response) => {
|
|
21
|
+
const url = new URL(request.url, 'http://localhost');
|
|
22
|
+
state.requests.push({ method: request.method, directory: url.searchParams.get('directory'), limit: url.searchParams.get('limit'), auth: request.headers['x-test-auth'] });
|
|
23
|
+
response.setHeader('content-type', 'application/json');
|
|
24
|
+
if (request.method === 'PATCH') {
|
|
25
|
+
const chunks = [];
|
|
26
|
+
for await (const chunk of request) chunks.push(chunk);
|
|
27
|
+
state.patches.push(JSON.parse(Buffer.concat(chunks).toString()));
|
|
28
|
+
response.end(JSON.stringify(state.session));
|
|
29
|
+
} else if (url.pathname.endsWith('/message')) {
|
|
30
|
+
response.end(JSON.stringify(url.searchParams.get('limit') === '1' ? state.messages.slice(-1) : state.messages));
|
|
31
|
+
} else {
|
|
32
|
+
state.gets++;
|
|
33
|
+
response.statusCode = state.failFresh && state.gets > 1 ? 500 : 200;
|
|
34
|
+
response.end(JSON.stringify(state.session));
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
38
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
39
|
+
state.base = base;
|
|
40
|
+
const runtime = createSessionAssistRuntime({
|
|
41
|
+
buildOpenCodeUrl: (route) => state.base + route,
|
|
42
|
+
getOpenCodeAuthHeaders: () => ({ 'x-test-auth': 'fixture' }),
|
|
43
|
+
getTargets: () => state.targets,
|
|
44
|
+
quietMs: 1,
|
|
45
|
+
getSmallModelService: async () => ({
|
|
46
|
+
describeSmallModel: async () => ({ inputCharBudget: 64_000 }),
|
|
47
|
+
generateSmallModelText: async (args) => { state.calls.push(args); return generate(args, state); },
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
resources.push({ runtime, server });
|
|
51
|
+
const status = (type) => runtime.processPayload({ type: 'session.status', properties: { sessionID: 'session', status: { type } } }, '/project');
|
|
52
|
+
return { state, runtime, status };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
afterEach(async () => {
|
|
56
|
+
for (const { runtime, server } of resources.splice(0)) {
|
|
57
|
+
runtime.stop();
|
|
58
|
+
server.closeAllConnections();
|
|
59
|
+
await new Promise((resolve) => server.close(resolve));
|
|
60
|
+
}
|
|
61
|
+
vi.restoreAllMocks();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('session assist runtime', () => {
|
|
65
|
+
it('uses bounded authenticated SDK reads and preserves metadata with an empty suggestion', async () => {
|
|
66
|
+
const { state, status } = await fixture(async (_args, current) => {
|
|
67
|
+
current.session.metadata.aiworker.concurrent = 'new';
|
|
68
|
+
return output();
|
|
69
|
+
});
|
|
70
|
+
status('idle');
|
|
71
|
+
await vi.waitFor(() => expect(state.patches).toHaveLength(1));
|
|
72
|
+
expect(state.requests.every((r) => r.directory === '/project' && r.auth === 'fixture')).toBe(true);
|
|
73
|
+
expect(state.requests.filter((r) => r.limit).map((r) => r.limit)).toEqual(['50', '1']);
|
|
74
|
+
expect(state.calls[0]).toMatchObject({ restrictToPreferredProvider: true, onOverflow: 'error', preferredProviderID: 'test-provider', preferredModelID: 'test-model', sessionID: 'session' });
|
|
75
|
+
expect(state.patches[0].metadata).toMatchObject({ external: 'keep', aiworker: {
|
|
76
|
+
note: 'keep', concurrent: 'new', assist: { recap: 'Зміни готові', suggestion: '', forMessageID: 'answer' },
|
|
77
|
+
} });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('does no work with both settings off and skips child, archived, or reverted sessions', async () => {
|
|
81
|
+
const { state, status } = await fixture();
|
|
82
|
+
state.targets = { recap: false, suggestion: false };
|
|
83
|
+
status('idle');
|
|
84
|
+
await pause();
|
|
85
|
+
expect(state.requests).toHaveLength(0);
|
|
86
|
+
state.targets.recap = true;
|
|
87
|
+
state.session.parentID = 'parent';
|
|
88
|
+
status('idle');
|
|
89
|
+
await vi.waitFor(() => expect(state.gets).toBe(1));
|
|
90
|
+
delete state.session.parentID;
|
|
91
|
+
state.session.revert = { messageID: 'user' };
|
|
92
|
+
status('idle');
|
|
93
|
+
await vi.waitFor(() => expect(state.gets).toBe(2));
|
|
94
|
+
delete state.session.revert;
|
|
95
|
+
state.session.time.archived = 1;
|
|
96
|
+
status('idle');
|
|
97
|
+
await vi.waitFor(() => expect(state.gets).toBe(3));
|
|
98
|
+
expect(state.calls).toHaveLength(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('keeps recent context for recap-only and performs no write for an empty suggestion-only result', async () => {
|
|
102
|
+
const { state, status } = await fixture();
|
|
103
|
+
state.targets.suggestion = false;
|
|
104
|
+
state.messages.unshift(message('previous-user', 'user', 'Попередня задача'), message('previous-answer', 'assistant', 'Зміст зробленого', { parentID: 'previous-user' }));
|
|
105
|
+
status('idle');
|
|
106
|
+
await vi.waitFor(() => expect(state.patches).toHaveLength(1));
|
|
107
|
+
expect(state.calls[0].prompt).toContain('Зміст зробленого');
|
|
108
|
+
expect(state.calls[0].system).not.toContain('suggestion');
|
|
109
|
+
state.targets = { recap: false, suggestion: true };
|
|
110
|
+
status('idle');
|
|
111
|
+
await vi.waitFor(() => expect(state.calls).toHaveLength(2));
|
|
112
|
+
await pause();
|
|
113
|
+
expect(state.patches).toHaveLength(1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('does not write a stale result when the tail moves during generation', async () => {
|
|
117
|
+
const { state, status } = await fixture(async (_args, current) => {
|
|
118
|
+
current.messages.push(message('new-user', 'user', 'Нова задача'));
|
|
119
|
+
return output();
|
|
120
|
+
});
|
|
121
|
+
status('idle');
|
|
122
|
+
await vi.waitFor(() => expect(state.requests.some((r) => r.limit === '1')).toBe(true));
|
|
123
|
+
await pause();
|
|
124
|
+
expect(state.patches).toHaveLength(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('does not fall back to stale metadata when the fresh read fails', async () => {
|
|
128
|
+
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
129
|
+
const { state, status } = await fixture();
|
|
130
|
+
state.failFresh = true;
|
|
131
|
+
status('idle');
|
|
132
|
+
await vi.waitFor(() => expect(warning).toHaveBeenCalled());
|
|
133
|
+
expect(state.gets).toBe(2);
|
|
134
|
+
expect(state.patches).toHaveLength(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('cancels old work and retains an expired newer idle timer until it can run', async () => {
|
|
138
|
+
const releases = [];
|
|
139
|
+
const { state, status } = await fixture(() => new Promise((resolve) => releases.push(resolve)));
|
|
140
|
+
status('idle');
|
|
141
|
+
await vi.waitFor(() => expect(releases).toHaveLength(1));
|
|
142
|
+
status('busy');
|
|
143
|
+
expect(state.calls[0].signal.aborted).toBe(true);
|
|
144
|
+
state.messages.push(message('next-user', 'user', 'Далі'), message('next-answer', 'assistant', 'Готово', { parentID: 'next-user' }));
|
|
145
|
+
status('idle');
|
|
146
|
+
await pause();
|
|
147
|
+
releases[0](output('Старий результат'));
|
|
148
|
+
await vi.waitFor(() => expect(releases).toHaveLength(2));
|
|
149
|
+
expect(state.patches).toHaveLength(0);
|
|
150
|
+
releases[1](output('Новий результат'));
|
|
151
|
+
await vi.waitFor(() => expect(state.patches).toHaveLength(1));
|
|
152
|
+
expect(state.patches[0].metadata.aiworker.assist).toMatchObject({ recap: 'Новий результат', forMessageID: 'next-answer' });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('aborts on stop and honors settings switched off during generation', async () => {
|
|
156
|
+
let release;
|
|
157
|
+
const { state, runtime, status } = await fixture(() => new Promise((resolve) => { release = resolve; }));
|
|
158
|
+
status('idle');
|
|
159
|
+
await vi.waitFor(() => expect(state.calls).toHaveLength(1));
|
|
160
|
+
runtime.stop();
|
|
161
|
+
expect(state.calls[0].signal.aborted).toBe(true);
|
|
162
|
+
release(output());
|
|
163
|
+
await pause();
|
|
164
|
+
expect(state.patches).toHaveLength(0);
|
|
165
|
+
const second = await fixture(async (_args, current) => {
|
|
166
|
+
current.targets = { recap: false, suggestion: false };
|
|
167
|
+
return output();
|
|
168
|
+
});
|
|
169
|
+
second.status('idle');
|
|
170
|
+
await vi.waitFor(() => expect(second.state.gets).toBe(2));
|
|
171
|
+
await pause();
|
|
172
|
+
expect(second.state.patches).toHaveLength(0);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('ignores historical user updates but cancels a new request during generation', async () => {
|
|
176
|
+
let release;
|
|
177
|
+
const { state, runtime, status } = await fixture(() => new Promise((resolve) => { release = resolve; }));
|
|
178
|
+
status('idle');
|
|
179
|
+
await vi.waitFor(() => expect(state.calls).toHaveLength(1));
|
|
180
|
+
const userUpdate = (created) => runtime.processPayload({ type: 'message.updated', properties: {
|
|
181
|
+
info: { id: 'user', sessionID: 'session', role: 'user', time: { created } },
|
|
182
|
+
} });
|
|
183
|
+
userUpdate(1);
|
|
184
|
+
expect(state.calls[0].signal.aborted).toBe(false);
|
|
185
|
+
userUpdate(Date.now());
|
|
186
|
+
expect(state.calls[0].signal.aborted).toBe(true);
|
|
187
|
+
release(output());
|
|
188
|
+
await pause();
|
|
189
|
+
expect(state.patches).toHaveLength(0);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('rejects endpoint changes before writing instead of carrying a session into a new runtime', async () => {
|
|
193
|
+
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
194
|
+
const { state, status } = await fixture(async (_args, current) => {
|
|
195
|
+
current.base = 'http://unreachable.invalid';
|
|
196
|
+
return output();
|
|
197
|
+
});
|
|
198
|
+
status('idle');
|
|
199
|
+
await vi.waitFor(() => expect(warning).toHaveBeenCalled());
|
|
200
|
+
expect(state.patches).toHaveLength(0);
|
|
201
|
+
expect(state.gets).toBe(1);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -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
|
}
|