@vantran-se/openchamber-web 1.22.1 → 1.22.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -3
- package/bin/cli.js +4 -1
- package/bin/cli.test.js +92 -0
- package/bin/lib/cli-startup.js +49 -0
- package/bin/lib/commands-startup.js +41 -6
- package/dist/assets/{CodeMirrorCommentWidgets-C53Q7pbX.js → CodeMirrorCommentWidgets-CV1HNV5l.js} +1 -1
- package/dist/assets/{DiffView-Dh1ey2x9.js → DiffView-DkkSa30n.js} +3 -3
- package/dist/assets/DirtyBranchSwitchDialog-DW4WaXPc.js +1 -0
- package/dist/assets/FilesView-DDu8zDy5.js +17 -0
- package/dist/assets/GitView-1Ixo8z-M.js +6 -0
- package/dist/assets/{JsonTreeView-Bl0lp9Rs.js → JsonTreeView-DvCLL7dT.js} +1 -1
- package/dist/assets/LinearIssuesView-Bs1rZrF2.js +1 -0
- package/dist/assets/{MarkdownImageGallery-bA0PS-tn.js → MarkdownImageGallery-DyJUc6h9.js} +1 -1
- package/dist/assets/MarkdownRendererImpl-Bdm8vMkS.js +12 -0
- package/dist/assets/{OnboardingScreen-D3kYJIb6.js → OnboardingScreen-Dg66B6oB.js} +1 -1
- package/dist/assets/{PatchDiff-D6NXAfJk.js → PatchDiff-CAduNjfZ.js} +1 -1
- package/dist/assets/{PierreDiffViewer-Ct2A13NO.js → PierreDiffViewer-CTIb1GYD.js} +2 -2
- package/dist/assets/PlanView-trDaszfq.js +5 -0
- package/dist/assets/SettingsView-DStMfBmy.js +28 -0
- package/dist/assets/{SettingsWindow-BiWDBRvb.js → SettingsWindow-DQQFnrSZ.js} +1 -1
- package/dist/assets/{ToolOutputDialog-BQexMQIm.js → ToolOutputDialog-SjY2XZlA.js} +6 -6
- package/dist/assets/ToolPartDiffPreview-DJdzUQPE.js +8 -0
- package/dist/assets/{VirtualizedFileDiff-Dpz2ra8h.js → VirtualizedFileDiff-Iehm4iMH.js} +1 -1
- package/dist/assets/{WalkthroughView-AX-u2pFg.js → WalkthroughView-BALp2d9O.js} +2 -2
- package/dist/assets/{computeVirtualFileMetrics-DtyozJdt.js → computeVirtualFileMetrics-BKZA43vN.js} +2 -2
- package/dist/assets/{contextPanelEmbeddedChat-C9EcRSDF.js → contextPanelEmbeddedChat-D2PM_pEv.js} +1 -1
- package/dist/assets/{de-DqVn5vme.js → de-DlSNLAcy.js} +6 -5
- package/dist/assets/{es-hj43Z53i.js → es-D1GbowZ3.js} +6 -5
- package/dist/assets/{fr-BmkZt2mF.js → fr-BPOLXIAY.js} +6 -5
- package/dist/assets/{index-DmhujXbT.js → index-Ce0LR32V.js} +1 -1
- package/dist/assets/index-D42fPvt4.css +1 -0
- package/dist/assets/index-DlFbCkEy.js +39 -0
- package/dist/assets/{isDiffPlainText-XyulMHxx.js → isDiffPlainText-DaB2u_Kv.js} +1 -1
- package/dist/assets/{isFilePlainText-B19Ddps1.js → isFilePlainText-yJZa2IAC.js} +1 -1
- package/dist/assets/{ja-DioMIEGF.js → ja-Dm2-m9io.js} +7 -6
- package/dist/assets/{ko-BqPgaQJb.js → ko-CIXlzQ5-.js} +7 -6
- package/dist/assets/main-B7qTsd_w.js +930 -0
- package/dist/assets/{main-B2a_JfKZ.js → main-CyQrD9dG.js} +2 -2
- package/dist/assets/{markdownCore-CWRCqSH2.js → markdownCore-CeEwGgEK.js} +2 -2
- package/dist/assets/{miniChat-B3CSU_Ps.js → miniChat-7cPEx4sV.js} +2 -2
- package/dist/assets/{mobile-QNcFIVCW.js → mobile-QtzkPTM8.js} +2 -2
- package/dist/assets/{patchFileDiff-BpMQZnYu.js → patchFileDiff-No55fDiD.js} +1 -1
- package/dist/assets/pl-B2zmkOpw.js +16 -0
- package/dist/assets/{pt-BR-BqH8fuCH.js → pt-BR-C-JE5H6y.js} +6 -5
- package/dist/assets/{renderElectronMiniChatApp-D77bmLqo.js → renderElectronMiniChatApp-ePTkBJNS.js} +2 -2
- package/dist/assets/renderMobileApp-D9RjB9DX.js +25 -0
- package/dist/assets/runtimeEndpointReset-DfJIWwwo.js +17 -0
- package/dist/assets/{scheduledTasksApi-BSRCtP6T.js → scheduledTasksApi-DuhdyYEa.js} +1 -1
- package/dist/assets/{shikiHighlight-DE-OwqGw.js → shikiHighlight-EHEg58-n.js} +1 -1
- package/dist/assets/tr-MJmRIzEY.js +7 -0
- package/dist/assets/{uk-BkkfxbOs.js → uk-BmD7eM8k.js} +6 -5
- package/dist/assets/{useAppFontEffects-COAeIX-D.js → useAppFontEffects-CuJPKtI1.js} +151 -180
- package/dist/assets/useInlineCommentController-DD7_7OP_.js +2 -0
- package/dist/assets/{useRootScrollLock-C0N9mCwz.js → useRootScrollLock-DRGAe8PN.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-data-BG3v1Ghq.js → vendor-codemirror-language-data-9ZzoH0o0.js} +2 -2
- package/dist/assets/vendor-zod-CK21ihK-.js +39 -0
- package/dist/assets/{web-components-BNMwa0qm.js → web-components-PttKBrhi.js} +1 -1
- package/dist/assets/{zh-CN-C6FMdVgv.js → zh-CN-JJHl4ehT.js} +6 -5
- package/dist/assets/{zh-TW-CdcMjDxv.js → zh-TW-DPc4DEEW.js} +6 -5
- package/dist/index.html +5 -5
- package/dist/mini-chat.html +4 -4
- package/dist/mobile.html +4 -4
- package/package.json +8 -4
- package/server/index.js +38 -5
- package/server/lib/agent-tool/runtime.js +2 -19
- package/server/lib/changelog/update-notes.js +82 -0
- package/server/lib/changelog/update-notes.test.js +59 -0
- package/server/lib/dev-tunnel/DOCUMENTATION.md +13 -4
- package/server/lib/dev-tunnel/runtime.js +4 -4
- package/server/lib/dev-tunnel/tunnel.test.js +20 -0
- package/server/lib/fs/DOCUMENTATION.md +2 -0
- package/server/lib/fs/routes.js +32 -21
- package/server/lib/fs/routes.test.js +77 -0
- package/server/lib/git/DOCUMENTATION.md +3 -1
- package/server/lib/git/routes.js +16 -0
- package/server/lib/git/service.js +107 -17
- package/server/lib/git/service.test.js +171 -0
- package/server/lib/mcp-reconnect/DOCUMENTATION.md +55 -0
- package/server/lib/mcp-reconnect/runtime.js +122 -0
- package/server/lib/mcp-reconnect/runtime.test.js +140 -0
- package/server/lib/message-queue/DOCUMENTATION.md +158 -0
- package/server/lib/message-queue/runtime.js +870 -0
- package/server/lib/message-queue/runtime.test.js +482 -0
- package/server/lib/openchamber-sessions/routes.js +102 -0
- package/server/lib/openchamber-sessions/routes.test.js +88 -0
- package/server/lib/opencode/DOCUMENTATION.md +13 -5
- package/server/lib/opencode/config-paths.test.js +116 -0
- package/server/lib/opencode/core-routes.js +1 -0
- package/server/lib/opencode/feature-routes-runtime.js +5 -0
- package/server/lib/opencode/input-history-scope.js +14 -0
- package/server/lib/opencode/managed-plugin-config.js +35 -0
- package/server/lib/opencode/openchamber-routes.test.js +2 -2
- package/server/lib/opencode/plugins.js +2 -2
- package/server/lib/opencode/providers.js +44 -2
- package/server/lib/opencode/providers.test.js +131 -4
- package/server/lib/opencode/routes.js +4 -4
- package/server/lib/opencode/server-startup-runtime.js +1 -1
- package/server/lib/opencode/settings-helpers.js +28 -0
- package/server/lib/opencode/settings-helpers.test.js +177 -0
- package/server/lib/opencode/settings-runtime.js +2 -2
- package/server/lib/opencode/settings-runtime.test.js +29 -0
- package/server/lib/opencode/shared.js +4 -1
- package/server/lib/opencode/shutdown-runtime.js +2 -0
- package/server/lib/opencode/skill-routes.js +2 -1
- package/server/lib/opencode/snippets.js +1 -2
- package/server/lib/package-manager.js +4 -29
- package/server/lib/package-manager.test.js +2 -2
- package/server/lib/quota/DOCUMENTATION.md +2 -1
- package/server/lib/quota/credentials/providers.js +4 -0
- package/server/lib/quota/credentials/store.js +1 -1
- package/server/lib/quota/credentials/store.test.js +4 -4
- package/server/lib/quota/providers/exe-dev.js +75 -0
- package/server/lib/quota/providers/exe-dev.test.js +47 -0
- package/server/lib/quota/providers/index.js +7 -0
- package/server/lib/quota/providers/opencode-go.js +1 -0
- package/server/lib/quota/providers/opencode-go.test.js +5 -1
- package/server/lib/quota/routes.js +2 -0
- package/server/lib/quota/utils/auth.js +1 -1
- package/server/lib/relay/DOCUMENTATION.md +1 -1
- package/server/lib/relay/host-client.test.js +9 -1
- package/server/lib/relay/tunnel-host.js +3 -1
- package/server/lib/relay/tunnel-host.test.js +9 -1
- package/server/lib/security/request-security.js +21 -6
- package/server/lib/security/request-security.test.js +42 -8
- package/server/lib/session-assist/runtime.js +1 -0
- package/server/lib/session-goal/DOCUMENTATION.md +20 -7
- package/server/lib/session-goal/create.js +3 -1
- package/server/lib/session-goal/runtime.js +77 -11
- package/server/lib/session-goal/runtime.test.js +426 -0
- package/server/lib/skills-catalog/DOCUMENTATION.md +1 -1
- package/server/lib/skills-catalog/install.js +3 -2
- package/server/lib/small-model/DOCUMENTATION.md +4 -0
- package/server/lib/small-model/call.js +8 -2
- package/server/lib/small-model/call.test.js +23 -0
- package/server/lib/small-model/index.js +2 -1
- package/server/lib/small-model/routes.js +2 -1
- package/server/lib/system-prompt/runtime.js +2 -21
- package/server/lib/terminal/DOCUMENTATION.md +11 -7
- package/server/lib/terminal/runtime.js +180 -26
- package/server/lib/terminal/runtime.test.js +553 -26
- package/server/lib/terminal/shells.js +17 -0
- package/server/lib/terminal/shells.test.js +32 -1
- package/server/lib/ui-auth/ui-auth.js +4 -1
- package/server/lib/ui-auth/ui-auth.test.js +16 -0
- package/server/lib/walkthrough/index.js +1 -0
- package/server/lib/walkthrough/routes.test.js +26 -5
- package/dist/assets/CommitSection-X-f2RgYr.js +0 -1
- package/dist/assets/FilesView-3ZYP72ki.js +0 -17
- package/dist/assets/GitView-CMuN7z2Z.js +0 -6
- package/dist/assets/LinearIssuesView-CQiUDU8b.js +0 -1
- package/dist/assets/MarkdownRendererImpl-BGQi2PLq.js +0 -12
- package/dist/assets/PlanView-6AUjzNQn.js +0 -5
- package/dist/assets/SettingsView-CHI8fFaK.js +0 -28
- package/dist/assets/ToolPartDiffPreview-DPAATNn6.js +0 -8
- package/dist/assets/index-L_ncV-6H.css +0 -1
- package/dist/assets/index-qES19dlJ.js +0 -7
- package/dist/assets/main-CYgSu6dx.js +0 -928
- package/dist/assets/pl-slULBF4R.js +0 -15
- package/dist/assets/renderMobileApp-BQ3Kdm-d.js +0 -25
- package/dist/assets/runtimeEndpointReset-CRGn1gVj.js +0 -16
- package/dist/assets/tr-CjEbw1Aw.js +0 -6
- package/dist/assets/useInlineCommentController-JsBboQwt.js +0 -2
- package/dist/assets/vendor-zod-BLfBSgBh.js +0 -39
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Ownership
|
|
4
4
|
|
|
5
|
-
`runtime.js` owns terminal identity, PTY processes,
|
|
5
|
+
`runtime.js` owns terminal identity, PTY processes, launch mode, session purpose, ordered output, bounded scrollback, WebSocket attachments, and lifecycle routes. `shells.js` discovers executable shell families, resolves the persisted shell ID, and builds the per-shell argv for interactive versus command launches. Clients own tab arrangement. Interactive terminals use stable IDs; project actions keep a stable UI tab and allocate a fresh terminal ID for each command execution. Electron uses this same runtime in-process; VS Code returns an explicit unsupported error.
|
|
6
6
|
|
|
7
7
|
## Protocol
|
|
8
8
|
|
|
@@ -19,21 +19,25 @@
|
|
|
19
19
|
|
|
20
20
|
HTTP remains the authenticated command plane for create, resize, appearance updates, restart, close, and force-kill. There is no SSE output or HTTP input compatibility path.
|
|
21
21
|
|
|
22
|
-
`GET /api/terminal/sessions` enumerates live sessions (optionally filtered by resolved `cwd`) so clients can adopt terminals their local tab projection does not know about — another device, a new browser tab, or cleared storage. `POST /api/terminal/touch` refreshes `lastActivity` for the listed session ids; open clients call it periodically so background tabs, which hold no WebSocket attachment, are not idle-reaped while a client still shows them.
|
|
22
|
+
`GET /api/terminal/sessions` enumerates live sessions (optionally filtered by resolved `cwd`) so clients can adopt terminals their local tab projection does not know about — another device, a new browser tab, or cleared storage. Listings include the effective launch mode and the normalized purpose, but never the command text. `POST /api/terminal/touch` refreshes `lastActivity` for the listed session ids; open clients call it periodically so background tabs, which hold no WebSocket attachment, are not idle-reaped while a client still shows them.
|
|
23
23
|
|
|
24
24
|
## PTY Lifecycle
|
|
25
25
|
|
|
26
26
|
- IDs are client-provided or generated with `randomUUID()`.
|
|
27
|
-
-
|
|
27
|
+
- Create defaults to interactive mode. Command mode requires a non-empty trimmed command no longer than the terminal input limit and launches the shell so the PTY exits when that command exits. Each session also carries a normalized purpose. Omitted purpose means `{ type: 'terminal' }`. Project actions use `{ type: 'project-action', actionId, executionId }`, and the server validates both IDs as non-empty bounded strings. Create responses and attach snapshots echo the effective mode and purpose.
|
|
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.
|
|
28
29
|
- Dimensions are bounded to 1-1000 columns and 1-500 rows; input is capped at 64 KiB.
|
|
29
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.
|
|
30
31
|
- PTY children explicitly clear `NODE_CHANNEL_FD`; daemon IPC descriptors are host-private and invalid after PTY descriptor cleanup.
|
|
31
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 Linux, PTY spawn is wrapped with `env -u ARGV0` because `bun-pty` merges the native OS environ and would otherwise reintroduce `ARGV0` after a JS-only delete.
|
|
32
|
-
- `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. Preference changes affect new sessions and explicit restarts, not running PTYs.
|
|
33
|
-
- PTY data and exit callbacks enter one FIFO queue.
|
|
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
|
+
- 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.
|
|
34
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.
|
|
35
|
-
- Exited sessions remain attachable until explicit close or
|
|
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
|
+
- 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 OpenChamber) the body also carries `code: "TERMINAL_CWD_MISSING"`. That is the one rejection the client can recover from: the session, not the terminal, is stranded, and the shared UI moves it to its project directory and starts a terminal there. Every other rejection stays generic; the runtime never substitutes a parent directory on its own.
|
|
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
|
+
- 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.
|
|
37
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.
|
|
38
42
|
|
|
39
43
|
## Security And Relay
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from './terminal-ws-protocol.js';
|
|
10
10
|
import { sanitizeTerminalHistoryChunk } from './history.js';
|
|
11
11
|
import { consumeTerminalThemeQueries, terminalThemeModeReport } from './theme-response.js';
|
|
12
|
-
import {
|
|
12
|
+
import { buildTerminalShellLaunch, createTerminalShellResolver, normalizeTerminalShell } from './shells.js';
|
|
13
13
|
import { stripAppImageArgv0Leak, resolveLinuxPtyLaunch } from '../inherited-env.js';
|
|
14
14
|
|
|
15
15
|
const MAX_SESSIONS = 20;
|
|
@@ -17,7 +17,69 @@ const MAX_HISTORY_BYTES = 512 * 1024;
|
|
|
17
17
|
const MAX_INPUT_CHARS = 65_536;
|
|
18
18
|
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
19
19
|
const TERMINATION_GRACE_MS = 1000;
|
|
20
|
+
const INTERACTIVE_TERMINAL_MODE = 'interactive';
|
|
21
|
+
const COMMAND_TERMINAL_MODE = 'command';
|
|
22
|
+
// Error code the create/restart routes attach when the requested cwd no longer
|
|
23
|
+
// exists. Mirrored by `TERMINAL_CWD_MISSING_CODE` in packages/ui/src/lib/terminalApi.ts.
|
|
24
|
+
const TERMINAL_CWD_MISSING_CODE = 'TERMINAL_CWD_MISSING';
|
|
25
|
+
const TERMINAL_PURPOSE = Object.freeze({ type: 'terminal' });
|
|
26
|
+
const MAX_PURPOSE_ID_CHARS = 128;
|
|
27
|
+
const OBJECT_TAG = '[object Object]';
|
|
20
28
|
const validateSize = (value, max) => Number.isInteger(value) && value >= 1 && value <= max;
|
|
29
|
+
const isString = (value) => String(value) === value;
|
|
30
|
+
const isObjectRecord = (value) => value != null && !Array.isArray(value) && Object.prototype.toString.call(value) === OBJECT_TAG;
|
|
31
|
+
const normalizeCreateMode = ({ mode, command }) => {
|
|
32
|
+
const normalizedMode = mode == null ? INTERACTIVE_TERMINAL_MODE : mode;
|
|
33
|
+
if (normalizedMode !== INTERACTIVE_TERMINAL_MODE && normalizedMode !== COMMAND_TERMINAL_MODE) throw new Error('Invalid terminal mode');
|
|
34
|
+
if (normalizedMode === INTERACTIVE_TERMINAL_MODE) {
|
|
35
|
+
if (command != null) throw new Error('Interactive terminal create does not accept a command');
|
|
36
|
+
return { mode: INTERACTIVE_TERMINAL_MODE, command: null };
|
|
37
|
+
}
|
|
38
|
+
if (!isString(command) || !command.trim()) throw new Error('Terminal command is required');
|
|
39
|
+
const trimmedCommand = command.trim();
|
|
40
|
+
if (trimmedCommand.length > MAX_INPUT_CHARS) throw new Error('Terminal command exceeds the input limit');
|
|
41
|
+
return { mode: COMMAND_TERMINAL_MODE, command: trimmedCommand };
|
|
42
|
+
};
|
|
43
|
+
const normalizePurposeId = (value, errorMessage) => {
|
|
44
|
+
if (!isString(value) || !value.trim()) throw new Error(errorMessage);
|
|
45
|
+
const normalized = value.trim();
|
|
46
|
+
if (normalized.length > MAX_PURPOSE_ID_CHARS) throw new Error(errorMessage);
|
|
47
|
+
return normalized;
|
|
48
|
+
};
|
|
49
|
+
const normalizeTerminalPurpose = (value) => {
|
|
50
|
+
if (value == null) return TERMINAL_PURPOSE;
|
|
51
|
+
if (!isObjectRecord(value)) throw new Error('Invalid terminal purpose');
|
|
52
|
+
if (value.type === 'terminal') return TERMINAL_PURPOSE;
|
|
53
|
+
if (value.type !== 'project-action') throw new Error('Invalid terminal purpose');
|
|
54
|
+
return {
|
|
55
|
+
type: 'project-action',
|
|
56
|
+
actionId: normalizePurposeId(value.actionId, 'Terminal project action id is required'),
|
|
57
|
+
executionId: normalizePurposeId(value.executionId, 'Terminal execution id is required'),
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
const getSessionPurpose = (session) => session.purpose ?? TERMINAL_PURPOSE;
|
|
61
|
+
const isPurposeActionMatch = (left, right) => {
|
|
62
|
+
if (left.type !== right.type) return false;
|
|
63
|
+
return left.type !== 'project-action' || left.actionId === right.actionId;
|
|
64
|
+
};
|
|
65
|
+
const findRunningActionSession = (sessions, resolvedCwd, purpose, path) => {
|
|
66
|
+
if (purpose.type !== 'project-action') return null;
|
|
67
|
+
for (const session of sessions.values()) {
|
|
68
|
+
if (session.status !== 'running') continue;
|
|
69
|
+
if (path.resolve(session.cwd) !== resolvedCwd) continue;
|
|
70
|
+
const sessionPurpose = getSessionPurpose(session);
|
|
71
|
+
if (sessionPurpose.type === 'project-action' && sessionPurpose.actionId === purpose.actionId) return session;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
};
|
|
75
|
+
const findPendingActionCreate = (pendingSessionCreates, resolvedCwd, purpose) => {
|
|
76
|
+
if (purpose.type !== 'project-action') return null;
|
|
77
|
+
for (const pending of pendingSessionCreates.values()) {
|
|
78
|
+
if (pending.cancelled || pending.cwd !== resolvedCwd) continue;
|
|
79
|
+
if (pending.purpose?.type === 'project-action' && pending.purpose.actionId === purpose.actionId) return pending;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
};
|
|
21
83
|
const trimHistory = (history) => {
|
|
22
84
|
const bytes = Buffer.from(history);
|
|
23
85
|
if (bytes.byteLength <= MAX_HISTORY_BYTES) return history;
|
|
@@ -54,13 +116,11 @@ export function createTerminalRuntime({
|
|
|
54
116
|
return ptyProviderPromise;
|
|
55
117
|
};
|
|
56
118
|
|
|
57
|
-
const spawnPty = async ({ cwd, cols, rows, themeMode, shell, loginShell }) => {
|
|
119
|
+
const spawnPty = async ({ cwd, cols, rows, themeMode, shell, loginShell, mode, command }) => {
|
|
58
120
|
const provider = await getPtyProvider();
|
|
59
121
|
const resolvedShell = await shellResolver.resolve(shell);
|
|
60
122
|
let lastError = null;
|
|
61
123
|
for (const executable of resolvedShell.executables) {
|
|
62
|
-
const args = loginShell ? getTerminalShellLoginArgs(executable) : [];
|
|
63
|
-
if (!args) throw new Error(`Terminal shell "${resolvedShell.id}" does not support login mode`);
|
|
64
124
|
try {
|
|
65
125
|
const env = { ...process.env, PATH: buildAugmentedPath(), TERM: 'xterm-256color', COLORTERM: 'truecolor', COLORFGBG: themeMode === 'light' ? '0;15' : '15;0' };
|
|
66
126
|
// The daemon's IPC fd is closed inside the PTY. An explicit override is
|
|
@@ -70,9 +130,11 @@ export function createTerminalRuntime({
|
|
|
70
130
|
// AppImage exports ARGV0; zsh would otherwise rewrite argv[0] for every command (#2588).
|
|
71
131
|
// bun-pty also merges the native OS environ, so wrap with `env -u ARGV0` on Linux.
|
|
72
132
|
stripAppImageArgv0Leak(env);
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
133
|
+
const shellLaunch = buildTerminalShellLaunch(executable, { mode, command, loginShell });
|
|
134
|
+
const launch = resolveLinuxPtyLaunch(shellLaunch.executable, shellLaunch.args);
|
|
135
|
+
const options = { name: 'xterm-256color', cwd, cols, rows, env };
|
|
136
|
+
if (process.platform === 'win32') options.useConpty = true;
|
|
137
|
+
return { process: await provider.spawn(launch.executable, launch.args, options), backend: provider.backend, shell: resolvedShell.id, loginShell };
|
|
76
138
|
} catch (error) { lastError = error; }
|
|
77
139
|
}
|
|
78
140
|
throw lastError ?? new Error('No executable shell found');
|
|
@@ -123,6 +185,7 @@ export function createTerminalRuntime({
|
|
|
123
185
|
const snapshot = (session) => ({
|
|
124
186
|
t: 'snapshot', v: 3, s: session.id, q: session.sequence, history: session.history,
|
|
125
187
|
status: session.status, exitCode: session.exitCode, signal: session.signal,
|
|
188
|
+
mode: session.mode ?? INTERACTIVE_TERMINAL_MODE, purpose: getSessionPurpose(session),
|
|
126
189
|
runtime, ptyBackend: session.backend,
|
|
127
190
|
});
|
|
128
191
|
|
|
@@ -175,11 +238,18 @@ export function createTerminalRuntime({
|
|
|
175
238
|
ptyProcess.onExit(({ exitCode, signal }) => { session.eventQueue.push({ type: 'exit', process: ptyProcess, exitCode, signal }); drainEvents(session); });
|
|
176
239
|
};
|
|
177
240
|
|
|
241
|
+
// A working directory that no longer exists (a deleted worktree) is the one
|
|
242
|
+
// rejection the client can recover from by moving the session to its
|
|
243
|
+
// project, so the response names it. Every other rejection stays generic.
|
|
244
|
+
const invalidWorkingDirectory = (code) => Object.assign(new Error('Invalid working directory'), code ? { code } : {});
|
|
178
245
|
const validateCwd = async (cwd) => {
|
|
179
246
|
if (typeof cwd !== 'string' || !cwd.trim()) throw new Error('cwd is required');
|
|
180
|
-
|
|
181
|
-
|
|
247
|
+
let stats;
|
|
248
|
+
try { stats = await fs.promises.stat(cwd); }
|
|
249
|
+
catch (error) { throw invalidWorkingDirectory(error?.code === 'ENOENT' || error?.code === 'ENOTDIR' ? TERMINAL_CWD_MISSING_CODE : undefined); }
|
|
250
|
+
if (!stats?.isDirectory()) throw invalidWorkingDirectory();
|
|
182
251
|
};
|
|
252
|
+
const errorBody = (error, fallback) => ({ error: error?.message || fallback, ...(typeof error?.code === 'string' ? { code: error.code } : {}) });
|
|
183
253
|
|
|
184
254
|
const applyAppearance = (session, { themeMode, terminalBackground, terminalForeground }) => {
|
|
185
255
|
const previous = [session.themeMode, session.terminalBackground, session.terminalForeground];
|
|
@@ -192,48 +262,87 @@ export function createTerminalRuntime({
|
|
|
192
262
|
}
|
|
193
263
|
};
|
|
194
264
|
|
|
195
|
-
const startSession = async (session, { cwd, cols, rows, themeMode = 'dark', terminalBackground, terminalForeground, shell, loginShell }, clear = true) => {
|
|
265
|
+
const startSession = async (session, { cwd, cols, rows, themeMode = 'dark', terminalBackground, terminalForeground, shell, loginShell, mode = INTERACTIVE_TERMINAL_MODE, command = null, purpose = TERMINAL_PURPOSE }, clear = true) => {
|
|
196
266
|
await validateCwd(cwd);
|
|
197
|
-
const spawned = await spawnPty({ cwd, cols, rows, themeMode, shell, loginShell });
|
|
267
|
+
const spawned = await spawnPty({ cwd, cols, rows, themeMode, shell, loginShell, mode, command });
|
|
198
268
|
if (clear) { session.history = ''; session.pendingHistoryControlSequence = ''; session.pendingThemeControlSequence = ''; session.themeModeEnabled = false; }
|
|
199
269
|
session.cwd = cwd; session.cols = cols; session.rows = rows; session.process = spawned.process;
|
|
200
270
|
session.backend = spawned.backend; session.shell = spawned.shell; session.loginShell = spawned.loginShell; session.status = 'running'; session.exitCode = null; session.signal = null;
|
|
271
|
+
session.mode = mode; session.command = mode === COMMAND_TERMINAL_MODE ? command : null;
|
|
272
|
+
session.purpose = purpose;
|
|
201
273
|
session.themeMode = themeMode === 'light' ? 'light' : 'dark'; session.terminalBackground = terminalBackground; session.terminalForeground = terminalForeground;
|
|
202
274
|
session.lastActivity = Date.now(); session.eventQueue.length = 0;
|
|
203
275
|
wire(session, spawned.process);
|
|
276
|
+
return spawned.process;
|
|
204
277
|
};
|
|
205
278
|
|
|
206
|
-
const createSession = async ({ sessionId, cwd, cols = 80, rows = 24, themeMode, terminalBackground, terminalForeground, shell = 'auto', loginShell = false }) => {
|
|
279
|
+
const createSession = async ({ sessionId, cwd, cols = 80, rows = 24, themeMode, terminalBackground, terminalForeground, shell = 'auto', loginShell = false, mode, command, purpose }) => {
|
|
207
280
|
if (!validateSize(cols, 1000) || !validateSize(rows, 500)) throw new Error('Invalid terminal dimensions');
|
|
208
281
|
if (typeof loginShell !== 'boolean') throw new Error('Invalid terminal login mode');
|
|
209
282
|
const normalizedShell = normalizeTerminalShell(shell);
|
|
210
283
|
if (!normalizedShell) throw new Error('Invalid terminal shell');
|
|
284
|
+
const launchMode = normalizeCreateMode({ mode, command });
|
|
285
|
+
const normalizedPurpose = normalizeTerminalPurpose(purpose);
|
|
211
286
|
const id = typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : randomUUID();
|
|
212
287
|
if (id.length > 128) throw new Error('Invalid terminal session id');
|
|
213
288
|
const existing = sessions.get(id);
|
|
214
289
|
const resolvedCwd = path.resolve(cwd);
|
|
215
290
|
if (existing?.status === 'running') {
|
|
216
291
|
if (path.resolve(existing.cwd) !== resolvedCwd) throw new Error('Terminal session belongs to a different working directory');
|
|
292
|
+
if (!isPurposeActionMatch(getSessionPurpose(existing), normalizedPurpose)) throw new Error('Terminal session is already running with a different purpose');
|
|
293
|
+
if (normalizedPurpose.type === 'project-action') { applyAppearance(existing, { themeMode, terminalBackground, terminalForeground }); return existing; }
|
|
294
|
+
if ((existing.mode ?? INTERACTIVE_TERMINAL_MODE) !== launchMode.mode) throw new Error('Terminal session is already running with a different mode');
|
|
295
|
+
if (launchMode.mode === COMMAND_TERMINAL_MODE && existing.command !== launchMode.command) throw new Error('Terminal session is already running with a different command');
|
|
217
296
|
applyAppearance(existing, { themeMode, terminalBackground, terminalForeground });
|
|
218
297
|
return existing;
|
|
219
298
|
}
|
|
299
|
+
const runningActionSession = findRunningActionSession(sessions, resolvedCwd, normalizedPurpose, path);
|
|
300
|
+
if (runningActionSession) {
|
|
301
|
+
applyAppearance(runningActionSession, { themeMode, terminalBackground, terminalForeground });
|
|
302
|
+
return runningActionSession;
|
|
303
|
+
}
|
|
220
304
|
const pending = pendingSessionCreates.get(id);
|
|
221
305
|
if (pending) {
|
|
222
306
|
if (pending.cwd !== resolvedCwd) throw new Error('Terminal session belongs to a different working directory');
|
|
307
|
+
if (!isPurposeActionMatch(pending.purpose, normalizedPurpose)) throw new Error('Terminal session is already being created with a different purpose');
|
|
308
|
+
if (normalizedPurpose.type === 'project-action') return pending.promise;
|
|
223
309
|
if (pending.shell !== normalizedShell) throw new Error('Terminal session is already being created with a different shell');
|
|
224
310
|
if (pending.loginShell !== loginShell) throw new Error('Terminal session is already being created with a different login mode');
|
|
311
|
+
if (pending.mode !== launchMode.mode) throw new Error('Terminal session is already being created with a different mode');
|
|
312
|
+
if (launchMode.mode === COMMAND_TERMINAL_MODE && pending.command !== launchMode.command) throw new Error('Terminal session is already being created with a different command');
|
|
225
313
|
const session = await pending.promise;
|
|
226
314
|
applyAppearance(session, { themeMode, terminalBackground, terminalForeground });
|
|
227
315
|
return session;
|
|
228
316
|
}
|
|
229
|
-
|
|
317
|
+
const pendingActionCreate = findPendingActionCreate(pendingSessionCreates, resolvedCwd, normalizedPurpose);
|
|
318
|
+
if (pendingActionCreate) {
|
|
319
|
+
const session = await pendingActionCreate.promise;
|
|
320
|
+
applyAppearance(session, { themeMode, terminalBackground, terminalForeground });
|
|
321
|
+
return session;
|
|
322
|
+
}
|
|
323
|
+
const superseded = normalizedPurpose.type === 'project-action'
|
|
324
|
+
? [...sessions.values()].filter((session) => session.id !== id && session.status === 'exited'
|
|
325
|
+
&& path.resolve(session.cwd) === resolvedCwd && isPurposeActionMatch(getSessionPurpose(session), normalizedPurpose))
|
|
326
|
+
: [];
|
|
327
|
+
if (!existing && sessions.size - superseded.length + pendingSessionCreates.size >= MAX_SESSIONS) throw new Error('Maximum terminal sessions reached');
|
|
328
|
+
const pendingEntry = { cwd: resolvedCwd, shell: normalizedShell, loginShell, mode: launchMode.mode, command: launchMode.command, purpose: normalizedPurpose, cancelled: false, promise: null };
|
|
230
329
|
const creation = (async () => {
|
|
231
330
|
const session = existing ?? { id, sequence: 0, history: '', pendingHistoryControlSequence: '', pendingThemeControlSequence: '', eventQueue: [], draining: false, createdAt: Date.now() };
|
|
232
|
-
await startSession(session, { cwd, cols, rows, themeMode, terminalBackground, terminalForeground, shell: normalizedShell, loginShell });
|
|
331
|
+
const ptyProcess = await startSession(session, { cwd, cols, rows, themeMode, terminalBackground, terminalForeground, shell: normalizedShell, loginShell, mode: launchMode.mode, command: launchMode.command, purpose: normalizedPurpose });
|
|
332
|
+
if (pendingEntry.cancelled) {
|
|
333
|
+
session.process = null;
|
|
334
|
+
await terminateProcess(ptyProcess, true);
|
|
335
|
+
throw new Error('Terminal session was closed during creation');
|
|
336
|
+
}
|
|
337
|
+
for (const previous of superseded) {
|
|
338
|
+
if (sessions.get(previous.id) !== previous || previous.status !== 'exited') continue;
|
|
339
|
+
sessions.delete(previous.id);
|
|
340
|
+
closeAttachments(previous.id, 'SUPERSEDED', 'Terminal replaced by a new action run');
|
|
341
|
+
}
|
|
233
342
|
sessions.set(id, session);
|
|
234
343
|
return session;
|
|
235
344
|
})();
|
|
236
|
-
|
|
345
|
+
pendingEntry.promise = creation;
|
|
237
346
|
pendingSessionCreates.set(id, pendingEntry);
|
|
238
347
|
try { return await creation; }
|
|
239
348
|
finally { if (pendingSessionCreates.get(id) === pendingEntry) pendingSessionCreates.delete(id); }
|
|
@@ -276,16 +385,39 @@ export function createTerminalRuntime({
|
|
|
276
385
|
|
|
277
386
|
const upgradeHandler = (req, socket, head) => {
|
|
278
387
|
if (parseRequestPathname(req.url) !== TERMINAL_WS_PATH) return;
|
|
279
|
-
|
|
388
|
+
const accept = () => {
|
|
389
|
+
if (!wsServer) { rejectWebSocketUpgrade(socket, 500, 'Terminal WebSocket unavailable'); return; }
|
|
280
390
|
try {
|
|
281
|
-
if (uiAuthController?.enabled) {
|
|
282
|
-
if (!await uiAuthController.ensureSessionToken(req, null)) { rejectWebSocketUpgrade(socket, 401, 'UI authentication required'); return; }
|
|
283
|
-
if (!await isRequestOriginAllowed(req)) { rejectWebSocketUpgrade(socket, 403, 'Invalid origin'); return; }
|
|
284
|
-
}
|
|
285
|
-
if (!wsServer) { rejectWebSocketUpgrade(socket, 500, 'Terminal WebSocket unavailable'); return; }
|
|
286
391
|
wsServer.handleUpgrade(req, socket, head, (ws) => wsServer.emit('connection', ws, req));
|
|
287
392
|
} catch { rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'); }
|
|
288
|
-
}
|
|
393
|
+
};
|
|
394
|
+
const checkOrigin = () => {
|
|
395
|
+
try {
|
|
396
|
+
const result = isRequestOriginAllowed(req);
|
|
397
|
+
if (!(result instanceof Promise)) {
|
|
398
|
+
if (result) accept();
|
|
399
|
+
else rejectWebSocketUpgrade(socket, 403, 'Invalid origin');
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
void result.then((allowed) => {
|
|
403
|
+
if (allowed) accept();
|
|
404
|
+
else rejectWebSocketUpgrade(socket, 403, 'Invalid origin');
|
|
405
|
+
}).catch(() => rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'));
|
|
406
|
+
} catch { rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'); }
|
|
407
|
+
};
|
|
408
|
+
if (!uiAuthController?.enabled) { accept(); return; }
|
|
409
|
+
try {
|
|
410
|
+
const result = uiAuthController.ensureSessionToken(req, null);
|
|
411
|
+
if (!(result instanceof Promise)) {
|
|
412
|
+
if (result) checkOrigin();
|
|
413
|
+
else rejectWebSocketUpgrade(socket, 401, 'UI authentication required');
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
void result.then((sessionToken) => {
|
|
417
|
+
if (sessionToken) checkOrigin();
|
|
418
|
+
else rejectWebSocketUpgrade(socket, 401, 'UI authentication required');
|
|
419
|
+
}).catch(() => rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'));
|
|
420
|
+
} catch { rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'); }
|
|
289
421
|
};
|
|
290
422
|
server.on('upgrade', upgradeHandler);
|
|
291
423
|
|
|
@@ -308,6 +440,8 @@ export function createTerminalRuntime({
|
|
|
308
440
|
cwd: session.cwd,
|
|
309
441
|
status: session.status,
|
|
310
442
|
createdAt: Number.isInteger(session.createdAt) ? session.createdAt : null,
|
|
443
|
+
mode: session.mode ?? INTERACTIVE_TERMINAL_MODE,
|
|
444
|
+
purpose: getSessionPurpose(session),
|
|
311
445
|
});
|
|
312
446
|
}
|
|
313
447
|
res.json({ sessions: list });
|
|
@@ -326,8 +460,18 @@ export function createTerminalRuntime({
|
|
|
326
460
|
res.json({ touched });
|
|
327
461
|
});
|
|
328
462
|
app.post('/api/terminal/create', async (req, res) => {
|
|
329
|
-
try {
|
|
330
|
-
|
|
463
|
+
try {
|
|
464
|
+
const session = await createSession(req.body ?? {});
|
|
465
|
+
res.json({
|
|
466
|
+
sessionId: session.id,
|
|
467
|
+
cols: session.cols,
|
|
468
|
+
rows: session.rows,
|
|
469
|
+
status: session.status,
|
|
470
|
+
mode: session.mode ?? INTERACTIVE_TERMINAL_MODE,
|
|
471
|
+
purpose: getSessionPurpose(session),
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
catch (error) { res.status(error?.message === 'Maximum terminal sessions reached' ? 429 : 400).json(errorBody(error, 'Failed to create terminal session')); }
|
|
331
475
|
});
|
|
332
476
|
app.post('/api/terminal/:sessionId/resize', (req, res) => {
|
|
333
477
|
const session = sessions.get(req.params.sessionId);
|
|
@@ -346,6 +490,7 @@ export function createTerminalRuntime({
|
|
|
346
490
|
app.post('/api/terminal/:sessionId/restart', async (req, res) => {
|
|
347
491
|
const session = sessions.get(req.params.sessionId);
|
|
348
492
|
if (!session) return res.status(404).json({ error: 'Terminal session not found' });
|
|
493
|
+
if ((session.mode ?? INTERACTIVE_TERMINAL_MODE) === COMMAND_TERMINAL_MODE) return res.status(400).json({ error: 'Command-mode terminal sessions cannot be restarted' });
|
|
349
494
|
const cwd = req.body?.cwd ?? session.cwd;
|
|
350
495
|
const cols = req.body?.cols ?? session.cols;
|
|
351
496
|
const rows = req.body?.rows ?? session.rows;
|
|
@@ -370,12 +515,21 @@ export function createTerminalRuntime({
|
|
|
370
515
|
try {
|
|
371
516
|
await restart;
|
|
372
517
|
res.json({ sessionId: session.id, cols, rows, status: session.status });
|
|
373
|
-
} catch (error) { res.status(400).json(
|
|
518
|
+
} catch (error) { res.status(400).json(errorBody(error, 'Failed to restart terminal')); }
|
|
374
519
|
finally { if (pendingSessionRestarts.get(session.id) === restart) pendingSessionRestarts.delete(session.id); }
|
|
375
520
|
});
|
|
376
521
|
app.delete('/api/terminal/:sessionId', async (req, res) => {
|
|
377
522
|
const session = sessions.get(req.params.sessionId);
|
|
378
|
-
if (!session)
|
|
523
|
+
if (!session) {
|
|
524
|
+
const pending = pendingSessionCreates.get(req.params.sessionId);
|
|
525
|
+
if (!pending) return res.status(404).json({ error: 'Terminal session not found' });
|
|
526
|
+
pending.cancelled = true;
|
|
527
|
+
try { await pending.promise; }
|
|
528
|
+
catch (error) {
|
|
529
|
+
if (error?.message !== 'Terminal session was closed during creation') throw error;
|
|
530
|
+
}
|
|
531
|
+
return res.json({ success: true });
|
|
532
|
+
}
|
|
379
533
|
sessions.delete(session.id);
|
|
380
534
|
closeAttachments(session.id, 'CLOSED', 'Terminal closed');
|
|
381
535
|
await terminateProcess(session.process);
|