ompchamber 0.7.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/index.html +1 -1
- package/dist/client/static/css/index.e880517fcd.css +2 -0
- package/dist/client/static/js/async/{3y.4c2596f64c.js → 3y.71f2ccf3b6.js} +2 -2
- package/dist/client/static/js/async/{xw.ae67ba06fd.js → 5b.0d6ae50fe8.js} +10 -10
- package/dist/client/static/js/async/5bh.8b2363424f.js +1 -0
- package/dist/client/static/js/async/{vy.bbe114c4e0.js → bi.09688ba75c.js} +1 -2
- package/dist/client/static/js/async/c.797d933882.js +1 -0
- package/dist/client/static/js/async/{dc.6dc4b0a04b.js → dc.856fda6560.js} +2 -2
- package/dist/client/static/js/async/g.a06ca3b3e9.js +2 -0
- package/dist/client/static/js/async/g.a06ca3b3e9.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{m.4b29d34f93.js → m.c038c71b9f.js} +2 -2
- package/dist/client/static/js/async/ma.2c261b04dd.js +2 -0
- package/dist/client/static/js/async/ms.98960af4eb.js +67 -0
- package/dist/client/static/js/async/nam.b5663e919f.js +10 -0
- package/dist/client/static/js/async/nam.b5663e919f.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/ot.84c71b308f.js +2 -0
- package/dist/client/static/js/async/ot.84c71b308f.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{q.542c184fec.js → q.23af2a358f.js} +3 -3
- package/dist/client/static/js/async/{s.413c2a4683.js → s.222ee974f6.js} +2 -2
- package/dist/client/static/js/async/t.4dca0e2ba8.js +1 -0
- package/dist/client/static/js/async/uf.46ad6209fa.js +2 -0
- package/dist/client/static/js/async/xn.6360379f71.js +2 -0
- package/dist/client/static/js/async/xnl.feb0021e50.js +2 -0
- package/dist/client/static/js/async/xnl.feb0021e50.js.LICENSE.txt +12 -0
- package/dist/client/static/js/index.0de15c5e17.js +33 -0
- package/dist/client/static/js/{uy.2e8929ed0c.js → jp.50817896f3.js} +6 -6
- package/package.json +2 -1
- package/src/client/components/common/MarkdownRenderer.tsx +69 -27
- package/src/client/components/common/code-editor/history.test.ts +118 -0
- package/src/client/components/common/code-editor/history.ts +101 -0
- package/src/client/components/common/code-editor/index.tsx +56 -66
- package/src/client/components/common/code-surface/index.tsx +84 -11
- package/src/client/components/common/code-surface/measure.ts +66 -17
- package/src/client/components/common/diagram-viewer/index.tsx +258 -0
- package/src/client/components/common/diagram-viewer/read-block.ts +53 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +2 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +11 -4
- package/src/client/components/layout/session-sidebar/SessionList.tsx +1 -1
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +1 -1
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +1 -1
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +2 -2
- package/src/client/components/settings/LazyModal.tsx +1 -1
- package/src/client/components/settings/Modal.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +11 -4
- package/src/client/components/workspace/chat-timeline/QueueList.tsx +18 -14
- package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +8 -1
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +11 -52
- package/src/client/components/workspace/chat-timeline/chat-input/selection.ts +57 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +124 -108
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/FrameControl.tsx +172 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +155 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/drafts.ts +113 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +92 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Footer.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Header.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Lazy.tsx +8 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/SelectBody.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/index.tsx +16 -10
- package/src/client/components/workspace/chat-timeline/tool-renderers/index.tsx +2 -2
- package/src/client/components/workspace/file-explorer/index.tsx +18 -5
- package/src/client/components/workspace/git-panel/BranchToolbar.tsx +2 -2
- package/src/client/components/workspace/git-panel/index.tsx +5 -1
- package/src/client/components/workspace/terminal-panel/Header.tsx +51 -19
- package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +86 -282
- package/src/client/components/workspace/terminal-panel/index.tsx +76 -50
- package/src/client/components/workspace/terminal-panel/mount.ts +275 -0
- package/src/client/data/theme/terminal.ts +27 -29
- package/src/client/hooks/chat/omp/session-list.tsx +23 -2
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +1 -1
- package/src/client/hooks/chat/timeline/actions.ts +23 -34
- package/src/client/hooks/chat/timeline/ask-frames.ts +102 -0
- package/src/client/hooks/chat/timeline/extension-dialog.ts +12 -9
- package/src/client/hooks/chat/timeline/index.ts +18 -28
- package/src/client/hooks/chat/timeline/prompt-text.ts +35 -0
- package/src/client/hooks/chat/timeline/queue.ts +110 -46
- package/src/client/hooks/chat/timeline/send.ts +13 -27
- package/src/client/hooks/chat/timeline/session-load.ts +48 -106
- package/src/client/hooks/chat/timeline/session-pagination.ts +144 -0
- package/src/client/hooks/editor/use-code-window.ts +118 -0
- package/src/client/hooks/ui/input-required-alert.ts +43 -0
- package/src/client/hooks/ui/notification-sound.ts +65 -0
- package/src/client/hooks/ui/theme.ts +22 -6
- package/src/client/hooks/workspace/terminal/index.ts +217 -0
- package/src/client/hooks/workspace/terminal/socket.ts +227 -0
- package/src/client/tailwind.css +38 -12
- package/src/server/db.server.ts +19 -0
- package/src/server/lib/fs/git-sync.ts +191 -0
- package/src/server/lib/omp/rpc/manager.ts +72 -6
- package/src/server/lib/omp/rpc/session-commands.ts +6 -0
- package/src/server/lib/omp/rpc/session-registry.ts +18 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +10 -4
- package/src/server/lib/queue/delivery.server.ts +114 -0
- package/src/server/lib/queue/schema-migration.server.ts +50 -0
- package/src/server/lib/queue/store.server.ts +185 -0
- package/src/server/lib/terminal/foreground.ts +73 -0
- package/src/server/lib/terminal/history.test.ts +118 -0
- package/src/server/lib/terminal/history.ts +205 -0
- package/src/server/lib/terminal/runtime.server.ts +338 -0
- package/src/server/lib/terminal/session.server.ts +215 -0
- package/src/server/lib/terminal/shell.test.ts +97 -0
- package/src/server/lib/terminal/shell.ts +103 -0
- package/src/server/routes/fs/git.ts +22 -12
- package/src/server/routes/index.ts +2 -1
- package/src/server/routes/sessions/index.ts +11 -3
- package/src/server/routes/sessions/queue.ts +121 -0
- package/src/server/routes/sessions/session.ts +1 -132
- package/src/server/routes/settings/commands.ts +1 -1
- package/src/server/routes/terminal/index.ts +2 -2
- package/src/server/routes/terminal/run.ts +8 -100
- package/src/server/routes/terminal/sessions.ts +24 -0
- package/src/server/routes/terminal/ws.ts +149 -0
- package/src/shared/lib/chat/ask-questions.test.ts +169 -0
- package/src/shared/lib/chat/ask-questions.ts +264 -0
- package/src/shared/lib/chat/omp/agent-events.test.ts +37 -1
- package/src/shared/lib/chat/omp/agent-events.ts +30 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +9 -1
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +45 -8
- package/src/shared/lib/code/lazy-window.test.ts +161 -0
- package/src/shared/lib/code/lazy-window.ts +156 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +50 -1
- package/src/shared/lib/code/syntax-highlight.ts +14 -1
- package/src/shared/lib/code/windowed-highlight.ts +63 -0
- package/src/shared/lib/markdown/mermaid.ts +8 -6
- package/src/shared/lib/omp/session/stream-state.server.ts +2 -3
- package/src/shared/lib/workspace/session-state/store.ts +1 -4
- package/src/shared/lib/workspace/terminal/protocol.ts +222 -0
- package/src/shared/types/omp/agent.ts +14 -2
- package/src/shared/types/terminal.ts +17 -15
- package/src/shared/types/workspace.ts +6 -2
- package/dist/client/static/css/index.51f68210a5.css +0 -2
- package/dist/client/static/js/async/5b.d88304bc13.js +0 -1
- package/dist/client/static/js/async/c.cd45b0ac45.js +0 -1
- package/dist/client/static/js/async/fw.6a51998a8e.js +0 -6
- package/dist/client/static/js/async/g.86e2256a70.js +0 -2
- package/dist/client/static/js/async/kc.bbdb35b771.js +0 -1
- package/dist/client/static/js/async/mw.ca1512563c.js +0 -10
- package/dist/client/static/js/async/rf.d626afc367.js +0 -2
- package/dist/client/static/js/async/t.cda4891526.js +0 -1
- package/dist/client/static/js/async/uf.24ca3d49c3.js +0 -2
- package/dist/client/static/js/async/uf.24ca3d49c3.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/v0.394ed37c6b.js +0 -2
- package/dist/client/static/js/async/v0.394ed37c6b.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/vy.bbe114c4e0.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/xn.afecebbbd9.js +0 -2
- package/dist/client/static/js/index.eccad0b70a.js +0 -33
- package/dist/client/static/js/index.eccad0b70a.js.LICENSE.txt +0 -4
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Ask.tsx +0 -205
- package/src/client/components/workspace/terminal-panel/InputBar.tsx +0 -82
- package/src/client/components/workspace/terminal-panel/LogEntry.tsx +0 -70
- package/src/client/hooks/workspace/terminal-output.ts +0 -66
- package/src/client/hooks/workspace/terminal.ts +0 -283
- package/src/server/lib/fs/terminal-cwd.ts +0 -49
- package/src/server/routes/terminal/stream.ts +0 -100
- /package/dist/client/static/js/async/{3y.4c2596f64c.js.LICENSE.txt → 3y.71f2ccf3b6.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.86e2256a70.js.LICENSE.txt → 5b.0d6ae50fe8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{dc.6dc4b0a04b.js.LICENSE.txt → dc.856fda6560.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{fw.6a51998a8e.js.LICENSE.txt → m.c038c71b9f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.4b29d34f93.js.LICENSE.txt → ma.2c261b04dd.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{xn.afecebbbd9.js.LICENSE.txt → ms.98960af4eb.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{mw.ca1512563c.js.LICENSE.txt → q.23af2a358f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.542c184fec.js.LICENSE.txt → s.222ee974f6.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{rf.d626afc367.js.LICENSE.txt → uf.46ad6209fa.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{s.413c2a4683.js.LICENSE.txt → xn.6360379f71.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{async/xw.ae67ba06fd.js.LICENSE.txt → index.0de15c5e17.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{uy.2e8929ed0c.js.LICENSE.txt → jp.50817896f3.js.LICENSE.txt} +0 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The terminal registry: which shells are live, who is watching them, and when
|
|
8
|
+
* they die.
|
|
9
|
+
*
|
|
10
|
+
* One real shell per terminal id, created on the first `attach` (there is no
|
|
11
|
+
* separate create endpoint to race against), kept alive while clients come and
|
|
12
|
+
* go, and killed through the process group so the commands it started die with
|
|
13
|
+
* it. Session mechanics live in `session.server.ts`; this module owns the map,
|
|
14
|
+
* the viewer sets, the idle reaper and process-exit cleanup.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import path from 'path';
|
|
18
|
+
import { detectBusyShells, type ShellProcess } from '@/server/lib/terminal/foreground';
|
|
19
|
+
import {
|
|
20
|
+
TERMINATION_GRACE_MS,
|
|
21
|
+
TerminalRuntimeError,
|
|
22
|
+
closeTerminalStream,
|
|
23
|
+
createSession,
|
|
24
|
+
signalGroup,
|
|
25
|
+
snapshotOf,
|
|
26
|
+
type AttachRequest,
|
|
27
|
+
type TerminalSession,
|
|
28
|
+
type TerminalViewer,
|
|
29
|
+
} from '@/server/lib/terminal/session.server';
|
|
30
|
+
import { TERMINAL_MAX_INPUT_BYTES, clampTerminalSize, type TerminalSnapshot } from '@/shared/lib/workspace/terminal/protocol';
|
|
31
|
+
|
|
32
|
+
const MAX_TERMINALS = 8;
|
|
33
|
+
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
34
|
+
const IDLE_SWEEP_MS = 60 * 1000;
|
|
35
|
+
/** Quiet period before a resize burst is committed to the PTY. */
|
|
36
|
+
const RESIZE_SETTLE_MS = 120;
|
|
37
|
+
|
|
38
|
+
export interface TerminalAttachment {
|
|
39
|
+
snapshot: TerminalSnapshot;
|
|
40
|
+
/** Retained scrollback, to be sent after the `ready` frame. */
|
|
41
|
+
replay: Uint8Array;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The registry lives on `globalThis`, not in a module-local binding.
|
|
46
|
+
*
|
|
47
|
+
* `bun run --hot` re-evaluates this module on every server-file edit, but the
|
|
48
|
+
* PTY children are detached sessions that keep running. A module-local `Map`
|
|
49
|
+
* would be replaced by a fresh empty one on each reload, leaving the previous
|
|
50
|
+
* shells alive with nothing tracking them — unreachable from the client, never
|
|
51
|
+
* counted, never reaped, and still holding their slots. Anchoring the map to
|
|
52
|
+
* the process keeps a reload from orphaning anything.
|
|
53
|
+
*/
|
|
54
|
+
declare global {
|
|
55
|
+
// eslint-disable-next-line no-var
|
|
56
|
+
var __ompChamberTerminals: Map<string, TerminalSession> | undefined;
|
|
57
|
+
// eslint-disable-next-line no-var
|
|
58
|
+
var __ompChamberTerminalSweep: ReturnType<typeof setInterval> | undefined;
|
|
59
|
+
// eslint-disable-next-line no-var
|
|
60
|
+
var __ompChamberTerminalResizes: Map<string, ReturnType<typeof setTimeout>> | undefined;
|
|
61
|
+
// eslint-disable-next-line no-var
|
|
62
|
+
var __ompChamberTerminalCleanupInstalled: boolean | undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const sessions: Map<string, TerminalSession> = (globalThis.__ompChamberTerminals ??= new Map());
|
|
66
|
+
|
|
67
|
+
function getSweepTimer() {
|
|
68
|
+
return globalThis.__ompChamberTerminalSweep;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function setSweepTimer(timer: ReturnType<typeof setInterval> | undefined) {
|
|
72
|
+
globalThis.__ompChamberTerminalSweep = timer;
|
|
73
|
+
return timer;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getResizeTimers(): Map<string, ReturnType<typeof setTimeout>> {
|
|
77
|
+
return (globalThis.__ompChamberTerminalResizes ??= new Map());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Attach `viewer` to `request.id`, creating the shell when the id is unknown.
|
|
82
|
+
*
|
|
83
|
+
* Registration and history capture happen in one synchronous block: Bun cannot
|
|
84
|
+
* interleave a PTY `data` callback inside it, so the viewer can neither miss
|
|
85
|
+
* output nor receive it twice.
|
|
86
|
+
*
|
|
87
|
+
* The cap counts *live* shells only. An exited shell is a corpse holding a
|
|
88
|
+
* scrollback nobody is reading, and letting corpses consume the budget is how
|
|
89
|
+
* a session that restarted a shell a few times hits the limit with nothing
|
|
90
|
+
* running: every restart leaves its predecessor behind.
|
|
91
|
+
*/
|
|
92
|
+
export async function attachTerminal(viewer: TerminalViewer, request: AttachRequest): Promise<TerminalAttachment> {
|
|
93
|
+
let session = sessions.get(request.id);
|
|
94
|
+
if (!session) {
|
|
95
|
+
reclaimExitedTerminals();
|
|
96
|
+
if ((await countLiveTerminals()) >= MAX_TERMINALS) {
|
|
97
|
+
throw new TerminalRuntimeError('capacity', `Terminal limit reached (${MAX_TERMINALS} busy shells)`);
|
|
98
|
+
}
|
|
99
|
+
const created = await createSession(request);
|
|
100
|
+
sessions.set(request.id, created);
|
|
101
|
+
// An exit with nobody watching is the common case for a replaced shell
|
|
102
|
+
// (restart, repo switch): reap it as soon as it lands instead of leaving
|
|
103
|
+
// the record for the sweep, which is up to a minute of holding a slot.
|
|
104
|
+
void created.proc?.exited.then(() => {
|
|
105
|
+
if (created.viewers.size === 0) forgetTerminal(created.id);
|
|
106
|
+
});
|
|
107
|
+
ensureSweeper();
|
|
108
|
+
session = created;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const replay = session.history.replay();
|
|
112
|
+
const snapshot = snapshotOf(session);
|
|
113
|
+
session.viewers.add(viewer);
|
|
114
|
+
session.lastActivity = Date.now();
|
|
115
|
+
return { snapshot, replay };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Shells that are actually doing work, which is what the cap budgets.
|
|
120
|
+
*
|
|
121
|
+
* Two things are deliberately excluded: an exited shell (a corpse with a
|
|
122
|
+
* scrollback) and a shell sitting at its prompt — the PTY's foreground process
|
|
123
|
+
* group tells them apart exactly (see `foreground.ts`). Counting idle shells
|
|
124
|
+
* is how a panel that was restarted or switched a few times pins the budget
|
|
125
|
+
* with nothing running, and the next attach is refused.
|
|
126
|
+
*/
|
|
127
|
+
export async function countLiveTerminals(): Promise<number> {
|
|
128
|
+
const running: ShellProcess[] = [];
|
|
129
|
+
for (const session of sessions.values()) {
|
|
130
|
+
if (session.status !== 'running' || !session.proc) continue;
|
|
131
|
+
running.push({ id: session.id, pid: session.proc.pid });
|
|
132
|
+
}
|
|
133
|
+
const busy = await detectBusyShells(running);
|
|
134
|
+
return busy.size;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Drop exited shells that nobody is watching.
|
|
139
|
+
*
|
|
140
|
+
* Run before a create, not only on the idle sweep: the sweep is a minute away,
|
|
141
|
+
* and a burst of restarts inside that window would otherwise be rejected while
|
|
142
|
+
* every corpse in the way is already dead.
|
|
143
|
+
*/
|
|
144
|
+
export function reclaimExitedTerminals(): number {
|
|
145
|
+
let reclaimed = 0;
|
|
146
|
+
for (const [id, session] of sessions) {
|
|
147
|
+
if (session.status !== 'exited') continue;
|
|
148
|
+
if (session.viewers.size > 0) continue;
|
|
149
|
+
forgetTerminal(id);
|
|
150
|
+
reclaimed += 1;
|
|
151
|
+
}
|
|
152
|
+
return reclaimed;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function detachTerminal(viewer: TerminalViewer, id: string): void {
|
|
156
|
+
const session = sessions.get(id);
|
|
157
|
+
if (!session) return;
|
|
158
|
+
session.viewers.delete(viewer);
|
|
159
|
+
session.lastActivity = Date.now();
|
|
160
|
+
// The last viewer leaving a finished shell leaves nothing behind worth
|
|
161
|
+
// keeping: no process to reattach to, and a scrollback nobody will read.
|
|
162
|
+
if (session.status === 'exited' && session.viewers.size === 0) forgetTerminal(id);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Live terminal summaries, optionally narrowed to one scope, so a client can
|
|
167
|
+
* adopt a shell it does not have an id for (a reloaded page, a second tab).
|
|
168
|
+
*
|
|
169
|
+
* `busy` marks a shell with a foreground command — the panel shows it as
|
|
170
|
+
* running, and the cap budgets it. One `ps` for the whole list.
|
|
171
|
+
*/
|
|
172
|
+
export async function listTerminals(root?: string | null, repo?: string | null): Promise<TerminalSnapshot[]> {
|
|
173
|
+
const scope = root ? (repo && repo !== '.' ? path.join(root, repo) : root) : null;
|
|
174
|
+
const running: ShellProcess[] = [];
|
|
175
|
+
const out: TerminalSnapshot[] = [];
|
|
176
|
+
for (const session of sessions.values()) {
|
|
177
|
+
if (scope && !isWithin(session.cwd, scope)) continue;
|
|
178
|
+
if (session.status === 'running' && session.proc) {
|
|
179
|
+
running.push({ id: session.id, pid: session.proc.pid });
|
|
180
|
+
}
|
|
181
|
+
out.push(snapshotOf(session));
|
|
182
|
+
}
|
|
183
|
+
const busy = await detectBusyShells(running);
|
|
184
|
+
return out.map((snapshot) => ({ ...snapshot, busy: busy.has(snapshot.id) }));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isWithin(target: string, root: string): boolean {
|
|
188
|
+
return target === root || target.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function writeTerminalInput(id: string, data: Uint8Array): void {
|
|
192
|
+
const session = sessions.get(id);
|
|
193
|
+
if (!session || session.status !== 'running') return;
|
|
194
|
+
if (data.length === 0 || data.length > TERMINAL_MAX_INPUT_BYTES) return;
|
|
195
|
+
session.lastActivity = Date.now();
|
|
196
|
+
try {
|
|
197
|
+
session.term.write(data);
|
|
198
|
+
} catch {
|
|
199
|
+
// The PTY closed between the status check and the write.
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function resizeTerminal(id: string, cols: number, rows: number): void {
|
|
204
|
+
const session = sessions.get(id);
|
|
205
|
+
if (!session || session.status !== 'running') return;
|
|
206
|
+
const size = clampTerminalSize(cols, rows);
|
|
207
|
+
if (size.cols === session.cols && size.rows === session.rows) return;
|
|
208
|
+
session.cols = size.cols;
|
|
209
|
+
session.rows = size.rows;
|
|
210
|
+
session.lastActivity = Date.now();
|
|
211
|
+
try {
|
|
212
|
+
session.term.resize(size.cols, size.rows);
|
|
213
|
+
} catch {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
// resize() updates the winsize but never signals the child, so a TUI would
|
|
217
|
+
// not repaint. Deliver SIGWINCH ourselves.
|
|
218
|
+
signalGroup(session, 'SIGWINCH');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Debounce for `resizeTerminal`.
|
|
223
|
+
*
|
|
224
|
+
* A panel drag produces a burst of sizes — the pointer moves in steps and
|
|
225
|
+
* xterm's FitAddon refits on every animation frame — and each accepted size
|
|
226
|
+
* sends SIGWINCH. The shell answers every one of them by redrawing its prompt,
|
|
227
|
+
* so a single drag used to print the prompt a dozen times (measured: 10
|
|
228
|
+
* SIGWINCH in one second for one drag).
|
|
229
|
+
*
|
|
230
|
+
* Only the trailing size matters: the PTY ends at the final grid either way,
|
|
231
|
+
* and a repaint of an intermediate size is invisible work. A single timeout is
|
|
232
|
+
* reused so the burst coalesces into one signal.
|
|
233
|
+
*/
|
|
234
|
+
const resizeTimers = getResizeTimers();
|
|
235
|
+
|
|
236
|
+
export function resizeTerminalDebounced(id: string, cols: number, rows: number): void {
|
|
237
|
+
const pending = resizeTimers.get(id);
|
|
238
|
+
if (pending) clearTimeout(pending);
|
|
239
|
+
resizeTimers.set(
|
|
240
|
+
id,
|
|
241
|
+
setTimeout(() => {
|
|
242
|
+
resizeTimers.delete(id);
|
|
243
|
+
resizeTerminal(id, cols, rows);
|
|
244
|
+
}, RESIZE_SETTLE_MS),
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Kill the shell for `id`, keeping the record and its viewers: the process exit
|
|
250
|
+
* is what reports the real exit code, so nothing is synthesized here.
|
|
251
|
+
*
|
|
252
|
+
* SIGHUP, not SIGTERM. An interactive shell ignores SIGTERM — verified on
|
|
253
|
+
* macOS: `kill -TERM` to a zsh process group left it alive indefinitely, while
|
|
254
|
+
* SIGHUP terminated it in ~150 ms along with its background jobs. SIGHUP is
|
|
255
|
+
* also the semantically right signal: the terminal is going away.
|
|
256
|
+
*/
|
|
257
|
+
export function closeTerminal(id: string): void {
|
|
258
|
+
const session = sessions.get(id);
|
|
259
|
+
if (!session || session.status !== 'running') return;
|
|
260
|
+
signalGroup(session, 'SIGHUP');
|
|
261
|
+
setTimeout(() => {
|
|
262
|
+
if (session.status === 'running') signalGroup(session, 'SIGKILL');
|
|
263
|
+
}, TERMINATION_GRACE_MS).unref?.();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Drop a session record and release its PTY stream. Used by the idle reaper and
|
|
268
|
+
* when a client closes a terminal it does not intend to reopen — there are no
|
|
269
|
+
* viewers left to inform, so a running shell is killed outright.
|
|
270
|
+
*/
|
|
271
|
+
export function forgetTerminal(id: string): void {
|
|
272
|
+
const session = sessions.get(id);
|
|
273
|
+
if (!session) return;
|
|
274
|
+
sessions.delete(id);
|
|
275
|
+
clearPendingResize(id);
|
|
276
|
+
session.viewers.clear();
|
|
277
|
+
if (session.status === 'running') signalGroup(session, 'SIGKILL');
|
|
278
|
+
closeTerminalStream(session);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function clearPendingResize(id: string): void {
|
|
282
|
+
const pending = resizeTimers.get(id);
|
|
283
|
+
if (pending) {
|
|
284
|
+
clearTimeout(pending);
|
|
285
|
+
resizeTimers.delete(id);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Kill every shell at process exit. Synchronous by contract — `exit` handlers
|
|
291
|
+
* cannot await — so it escalates straight to SIGKILL: a detached shell is its
|
|
292
|
+
* own session and would otherwise outlive the server.
|
|
293
|
+
*/
|
|
294
|
+
export function disposeAllTerminals(): void {
|
|
295
|
+
clearInterval(setSweepTimer(undefined));
|
|
296
|
+
for (const [id, session] of sessions) {
|
|
297
|
+
clearPendingResize(id);
|
|
298
|
+
session.viewers.clear();
|
|
299
|
+
signalGroup(session, 'SIGKILL');
|
|
300
|
+
closeTerminalStream(session);
|
|
301
|
+
}
|
|
302
|
+
sessions.clear();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function ensureSweeper(): void {
|
|
306
|
+
if (getSweepTimer() !== undefined) return;
|
|
307
|
+
setSweepTimer(setInterval(() => {
|
|
308
|
+
// Exited shells are reaped on exit and on detach; this catches the one
|
|
309
|
+
// that finished while a viewer was still attached to it.
|
|
310
|
+
reclaimExitedTerminals();
|
|
311
|
+
const now = Date.now();
|
|
312
|
+
for (const [id, session] of sessions) {
|
|
313
|
+
// A live shell is only reaped when nobody watches it: an attached
|
|
314
|
+
// terminal is idle by definition while the user reads its output.
|
|
315
|
+
if (session.viewers.size > 0) continue;
|
|
316
|
+
if (now - session.lastActivity < IDLE_TIMEOUT_MS) continue;
|
|
317
|
+
forgetTerminal(id);
|
|
318
|
+
}
|
|
319
|
+
}, IDLE_SWEEP_MS));
|
|
320
|
+
getSweepTimer()!.unref();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Register process-wide cleanup exactly once. A `bun --hot` reload re-evaluates
|
|
325
|
+
* this module while the shells live on, so the guard keeps a reload from
|
|
326
|
+
* stacking duplicate signal handlers.
|
|
327
|
+
*/
|
|
328
|
+
declare global {
|
|
329
|
+
// eslint-disable-next-line no-var
|
|
330
|
+
var __ompChamberTerminalCleanupInstalled: boolean | undefined;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (!globalThis.__ompChamberTerminalCleanupInstalled) {
|
|
334
|
+
globalThis.__ompChamberTerminalCleanupInstalled = true;
|
|
335
|
+
process.once('exit', disposeAllTerminals);
|
|
336
|
+
process.once('SIGINT', disposeAllTerminals);
|
|
337
|
+
process.once('SIGTERM', disposeAllTerminals);
|
|
338
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One PTY-backed shell: how it is created, how it is signalled, and what a
|
|
8
|
+
* snapshot of it looks like.
|
|
9
|
+
*
|
|
10
|
+
* Three behaviours here are load-bearing and were verified against Bun 1.4.2.
|
|
11
|
+
* Each fails *silently* when broken, which is why they are stated rather than
|
|
12
|
+
* implied by the code:
|
|
13
|
+
*
|
|
14
|
+
* 1. **`detached: true` is mandatory.** Without it the child stays in the
|
|
15
|
+
* server's own process group: an interactive shell reports "no job control
|
|
16
|
+
* in this shell" (Ctrl+Z and `fg` stop working), `process.kill(-pid, …)`
|
|
17
|
+
* fails with ESRCH, and a group signal aimed at the *server's* group would
|
|
18
|
+
* take the chamber down with it.
|
|
19
|
+
* 2. **The shell must re-acquire the PTY as its controlling terminal.** `setsid`
|
|
20
|
+
* drops it, and a shell without one disables job control regardless of
|
|
21
|
+
* `-i`. `shellLaunch` performs the re-open; see that module.
|
|
22
|
+
* 3. **`Bun.Terminal.resize()` never signals the child** — it updates the
|
|
23
|
+
* kernel winsize only, so a TUI would not repaint. The caller sends
|
|
24
|
+
* SIGWINCH itself.
|
|
25
|
+
*
|
|
26
|
+
* A fourth: a PTY read may split a multi-byte character, so bytes are forwarded
|
|
27
|
+
* verbatim and xterm's own streaming decoder reassembles them.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { resolveRoot } from '@/server/lib/fs/root';
|
|
31
|
+
import { scopeToRepo } from '@/server/lib/fs/repo-scope';
|
|
32
|
+
import { createTerminalHistory, type TerminalHistory } from '@/server/lib/terminal/history';
|
|
33
|
+
import { resolveShellExecutable, shellCandidates, shellLaunch, terminalEnv } from '@/server/lib/terminal/shell';
|
|
34
|
+
import {
|
|
35
|
+
clampTerminalSize,
|
|
36
|
+
type TerminalErrorCode,
|
|
37
|
+
type TerminalSnapshot,
|
|
38
|
+
} from '@/shared/lib/workspace/terminal/protocol';
|
|
39
|
+
|
|
40
|
+
export const TERMINATION_GRACE_MS = 1000;
|
|
41
|
+
|
|
42
|
+
/** A socket attached to a terminal. Output is raw PTY bytes; control is JSON. */
|
|
43
|
+
export interface TerminalViewer {
|
|
44
|
+
send(frame: string | Uint8Array): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface TerminalSession {
|
|
48
|
+
id: string;
|
|
49
|
+
cwd: string;
|
|
50
|
+
shell: string;
|
|
51
|
+
term: Bun.Terminal;
|
|
52
|
+
proc: Bun.Subprocess | null;
|
|
53
|
+
history: TerminalHistory;
|
|
54
|
+
viewers: Set<TerminalViewer>;
|
|
55
|
+
cols: number;
|
|
56
|
+
rows: number;
|
|
57
|
+
/** Grid the retained scrollback was drawn for — replay must match it. */
|
|
58
|
+
replayCols: number;
|
|
59
|
+
replayRows: number;
|
|
60
|
+
status: 'running' | 'exited';
|
|
61
|
+
exitCode: number | null;
|
|
62
|
+
signal: string | null;
|
|
63
|
+
lastActivity: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A rejected attach, carrying the code the client shows to the user. */
|
|
67
|
+
export class TerminalRuntimeError extends Error {
|
|
68
|
+
constructor(
|
|
69
|
+
readonly code: TerminalErrorCode,
|
|
70
|
+
message: string,
|
|
71
|
+
) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.name = 'TerminalRuntimeError';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface AttachRequest {
|
|
78
|
+
id: string;
|
|
79
|
+
cols: number;
|
|
80
|
+
rows: number;
|
|
81
|
+
root?: string;
|
|
82
|
+
repo?: string;
|
|
83
|
+
theme?: 'light' | 'dark';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function createSession(request: AttachRequest): Promise<TerminalSession> {
|
|
87
|
+
const cwd = await resolveSessionCwd(request.root, request.repo);
|
|
88
|
+
const executable = await resolveShellExecutable(shellCandidates());
|
|
89
|
+
if (!executable) {
|
|
90
|
+
throw new TerminalRuntimeError('shell', 'No usable shell found (checked $SHELL, /bin/zsh, /bin/bash, /bin/sh)');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const size = clampTerminalSize(request.cols, request.rows);
|
|
94
|
+
const session: TerminalSession = {
|
|
95
|
+
id: request.id,
|
|
96
|
+
cwd,
|
|
97
|
+
shell: executable,
|
|
98
|
+
// Assigned below: the PTY callback closes over `session`, so the record has
|
|
99
|
+
// to exist before the terminal does.
|
|
100
|
+
term: undefined as unknown as Bun.Terminal,
|
|
101
|
+
proc: null,
|
|
102
|
+
history: createTerminalHistory(),
|
|
103
|
+
viewers: new Set(),
|
|
104
|
+
cols: size.cols,
|
|
105
|
+
rows: size.rows,
|
|
106
|
+
replayCols: size.cols,
|
|
107
|
+
replayRows: size.rows,
|
|
108
|
+
status: 'running',
|
|
109
|
+
exitCode: null,
|
|
110
|
+
signal: null,
|
|
111
|
+
lastActivity: Date.now(),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const term = new Bun.Terminal({
|
|
115
|
+
cols: size.cols,
|
|
116
|
+
rows: size.rows,
|
|
117
|
+
name: 'xterm-256color',
|
|
118
|
+
data(_term, bytes) {
|
|
119
|
+
session.history.append(bytes);
|
|
120
|
+
session.lastActivity = Date.now();
|
|
121
|
+
for (const viewer of session.viewers) viewer.send(bytes);
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
session.term = term;
|
|
125
|
+
|
|
126
|
+
let proc: Bun.Subprocess;
|
|
127
|
+
try {
|
|
128
|
+
proc = Bun.spawn(shellLaunch(executable), {
|
|
129
|
+
terminal: term,
|
|
130
|
+
detached: true,
|
|
131
|
+
cwd,
|
|
132
|
+
env: terminalEnv(Bun.env, request.theme ?? 'dark'),
|
|
133
|
+
});
|
|
134
|
+
} catch (error) {
|
|
135
|
+
closeTerminalStream(session);
|
|
136
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
137
|
+
throw new TerminalRuntimeError('spawn', `Failed to start ${executable}: ${message}`);
|
|
138
|
+
}
|
|
139
|
+
session.proc = proc;
|
|
140
|
+
|
|
141
|
+
void proc.exited.then((code) => {
|
|
142
|
+
if (session.status === 'exited') return;
|
|
143
|
+
session.status = 'exited';
|
|
144
|
+
session.exitCode = code ?? null;
|
|
145
|
+
session.signal = proc.signalCode ?? null;
|
|
146
|
+
session.lastActivity = Date.now();
|
|
147
|
+
const frame = JSON.stringify({ t: 'exit', exitCode: session.exitCode, signal: session.signal });
|
|
148
|
+
for (const viewer of session.viewers) viewer.send(frame);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return session;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function resolveSessionCwd(root: string | undefined, repo: string | undefined): Promise<string> {
|
|
155
|
+
const baseDir = await resolveRoot(root ?? null, process.cwd());
|
|
156
|
+
let scoped = baseDir;
|
|
157
|
+
try {
|
|
158
|
+
scoped = await scopeToRepo(baseDir, repo ?? null);
|
|
159
|
+
} catch {
|
|
160
|
+
throw new TerminalRuntimeError('cwd', 'Invalid repo path');
|
|
161
|
+
}
|
|
162
|
+
const stat = await Bun.file(scoped).stat().catch(() => null);
|
|
163
|
+
if (!stat?.isDirectory()) throw new TerminalRuntimeError('cwd', `Not a directory: ${scoped}`);
|
|
164
|
+
return scoped;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function snapshotOf(session: TerminalSession): TerminalSnapshot {
|
|
168
|
+
return {
|
|
169
|
+
id: session.id,
|
|
170
|
+
cwd: session.cwd,
|
|
171
|
+
shell: session.shell,
|
|
172
|
+
cols: session.cols,
|
|
173
|
+
rows: session.rows,
|
|
174
|
+
status: session.status,
|
|
175
|
+
exitCode: session.exitCode,
|
|
176
|
+
signal: session.signal,
|
|
177
|
+
bunVersion: Bun.version,
|
|
178
|
+
// `process.version` is the Node version Bun emulates, not a real binary.
|
|
179
|
+
nodeVersion: process.version,
|
|
180
|
+
replayCols: session.replayCols,
|
|
181
|
+
replayRows: session.replayRows,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Signal the shell's whole process group.
|
|
187
|
+
*
|
|
188
|
+
* `detached: true` made the shell a session leader, so the group holds every
|
|
189
|
+
* command it started — a build, a dev server, a suspended job. Signalling the
|
|
190
|
+
* pid alone would leave those behind.
|
|
191
|
+
*/
|
|
192
|
+
export function signalGroup(session: TerminalSession, signal: NodeJS.Signals): void {
|
|
193
|
+
const pid = session.proc?.pid;
|
|
194
|
+
if (!pid || process.platform === 'win32') {
|
|
195
|
+
try {
|
|
196
|
+
session.proc?.kill(signal);
|
|
197
|
+
} catch {
|
|
198
|
+
// Already gone.
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
process.kill(-pid, signal);
|
|
204
|
+
} catch {
|
|
205
|
+
// The group is gone: the shell exited on its own.
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function closeTerminalStream(session: TerminalSession): void {
|
|
210
|
+
try {
|
|
211
|
+
session.term.close();
|
|
212
|
+
} catch {
|
|
213
|
+
// Already closed.
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
resolveShellExecutable,
|
|
10
|
+
shellArgs,
|
|
11
|
+
shellCandidates,
|
|
12
|
+
shellLaunch,
|
|
13
|
+
terminalEnv,
|
|
14
|
+
} from '@/server/lib/terminal/shell';
|
|
15
|
+
|
|
16
|
+
describe('shellCandidates', () => {
|
|
17
|
+
test('prefers an absolute $SHELL over the platform defaults', () => {
|
|
18
|
+
expect(shellCandidates({ SHELL: '/opt/homebrew/bin/fish' }, 'darwin')[0]).toBe('/opt/homebrew/bin/fish');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('ignores a relative $SHELL so PATH cannot choose the login shell', () => {
|
|
22
|
+
expect(shellCandidates({ SHELL: 'fish' }, 'darwin')).toEqual(['/bin/zsh', '/bin/bash', '/bin/sh']);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('falls back to the platform defaults without $SHELL', () => {
|
|
26
|
+
expect(shellCandidates({}, 'linux')).toEqual(['/bin/zsh', '/bin/bash', '/bin/sh']);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('uses COMSPEC on windows', () => {
|
|
30
|
+
expect(shellCandidates({ COMSPEC: 'C:\\Windows\\System32\\cmd.exe' }, 'win32')).toEqual([
|
|
31
|
+
'C:\\Windows\\System32\\cmd.exe',
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('shellArgs', () => {
|
|
37
|
+
test('POSIX shells launch interactive and login', () => {
|
|
38
|
+
// Interactive is what enables job control; without it Ctrl+Z/`fg` are dead.
|
|
39
|
+
expect(shellArgs('darwin')).toEqual(['-i', '-l']);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('cmd.exe takes neither flag', () => {
|
|
43
|
+
expect(shellArgs('win32')).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('shellLaunch', () => {
|
|
48
|
+
test('re-acquires the controlling terminal before exec on POSIX', () => {
|
|
49
|
+
// Without this, a detached child has no controlling tty and the shell
|
|
50
|
+
// silently disables job control.
|
|
51
|
+
const argv = shellLaunch('/bin/zsh', 'darwin');
|
|
52
|
+
expect(argv[0]).toBe('/bin/sh');
|
|
53
|
+
expect(argv[1]).toBe('-c');
|
|
54
|
+
expect(argv[2]).toContain('tty');
|
|
55
|
+
expect(argv[2]).toContain('exec /bin/zsh -i -l');
|
|
56
|
+
// `exec` matters: it keeps the pid, so the shell stays the session leader
|
|
57
|
+
// and a group signal still reaches everything it starts.
|
|
58
|
+
expect(argv[2].match(/exec /g)?.length).toBe(2);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('runs the shell directly on windows', () => {
|
|
62
|
+
expect(shellLaunch('cmd.exe', 'win32')).toEqual(['cmd.exe']);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('terminalEnv', () => {
|
|
67
|
+
test('describes the renderer and theme to the child', () => {
|
|
68
|
+
const env = terminalEnv({ PATH: '/usr/bin', COLORFGBG: 'stale' }, 'light');
|
|
69
|
+
expect(env.TERM).toBe('xterm-256color');
|
|
70
|
+
expect(env.COLORTERM).toBe('truecolor');
|
|
71
|
+
expect(env.COLORFGBG).toBe('0;15');
|
|
72
|
+
expect(env.PATH).toBe('/usr/bin');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('drops the chamber IPC descriptor', () => {
|
|
76
|
+
// An inherited NODE_CHANNEL_FD makes Node CLIs print IPC parse errors.
|
|
77
|
+
const env = terminalEnv({ NODE_CHANNEL_FD: '3' }, 'dark');
|
|
78
|
+
expect('NODE_CHANNEL_FD' in env).toBe(false);
|
|
79
|
+
expect(env.COLORFGBG).toBe('15;0');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('resolveShellExecutable', () => {
|
|
84
|
+
test('returns the first candidate that exists', async () => {
|
|
85
|
+
const checked: string[] = [];
|
|
86
|
+
const resolved = await resolveShellExecutable(['/nope/zsh', '/bin/bash'], async (target) => {
|
|
87
|
+
checked.push(target);
|
|
88
|
+
return target === '/bin/bash';
|
|
89
|
+
});
|
|
90
|
+
expect(resolved).toBe('/bin/bash');
|
|
91
|
+
expect(checked).toEqual(['/nope/zsh', '/bin/bash']);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('reports no shell when nothing exists', async () => {
|
|
95
|
+
expect(await resolveShellExecutable(['/nope'], async () => false)).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
});
|