ompchamber 0.8.0 → 1.1.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.b859cc1cc5.css +2 -0
- package/dist/client/static/js/async/{5b.0d6ae50fe8.js → 5b.a5ccbc19dc.js} +2 -2
- package/dist/client/static/js/async/g.a06ca3b3e9.js +2 -0
- package/dist/client/static/js/async/{m.0896dab823.js → m.c038c71b9f.js} +2 -2
- package/dist/client/static/js/async/ms.98960af4eb.js +67 -0
- package/dist/client/static/js/async/ms.98960af4eb.js.LICENSE.txt +12 -0
- package/dist/client/static/js/async/ot.84c71b308f.js +2 -0
- package/dist/client/static/js/async/{x4.69ec69c61b.js.LICENSE.txt → ot.84c71b308f.js.LICENSE.txt} +5 -0
- package/dist/client/static/js/index.6e065017eb.js +33 -0
- package/package.json +2 -1
- package/src/client/components/common/ComposerPicker.tsx +16 -4
- package/src/client/components/common/ComposerTextarea.tsx +1 -0
- package/src/client/components/layout/RightActivityBar.tsx +1 -1
- package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
- package/src/client/components/layout/desktop-layout/index.tsx +25 -96
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
- package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -1
- package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +27 -13
- package/src/client/components/layout/session-sidebar/SessionList.tsx +1 -1
- package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
- 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/workspace/chat-timeline/ComposerDock.tsx +14 -4
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
- package/src/client/components/workspace/chat-timeline/QueueList.tsx +18 -14
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +23 -3
- package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
- 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/model-dropdown/groups.ts +35 -2
- package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
- package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
- 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/composer/index.ts +28 -8
- package/src/client/hooks/chat/omp/session-list.tsx +19 -2
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +1 -1
- package/src/client/hooks/chat/timeline/actions.ts +47 -28
- package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
- package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
- package/src/client/hooks/chat/timeline/index.ts +25 -21
- package/src/client/hooks/chat/timeline/queue.ts +110 -46
- package/src/client/hooks/chat/timeline/send.ts +16 -5
- package/src/client/hooks/workspace/available-width.ts +33 -0
- package/src/client/hooks/workspace/panel-widths.ts +34 -37
- package/src/client/hooks/workspace/terminal/index.ts +217 -0
- package/src/client/hooks/workspace/terminal/socket.ts +227 -0
- package/src/server/db.server.ts +19 -0
- package/src/server/lib/fs/git-sync.ts +191 -0
- package/src/server/lib/models/preferences.server.ts +110 -0
- package/src/server/lib/models/registry.server.ts +206 -0
- package/src/server/lib/omp/rpc/manager.ts +58 -3
- package/src/server/lib/omp/rpc/session-commands.ts +6 -0
- package/src/server/lib/omp/rpc/utility.ts +7 -1
- package/src/server/lib/omp/session/sidebar-data.server.ts +3 -3
- package/src/server/lib/queue/delivery.server.test.ts +203 -0
- package/src/server/lib/queue/delivery.server.ts +144 -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/models/root.ts +88 -215
- 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 +59 -18
- 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/composer/client.ts +17 -8
- package/src/shared/lib/chat/composer/commands.ts +290 -0
- package/src/shared/lib/chat/composer/composer.test.ts +157 -0
- package/src/shared/lib/chat/composer/filter.ts +21 -7
- package/src/shared/lib/chat/composer/trigger.ts +109 -20
- 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/models/preferences.test.ts +131 -0
- package/src/shared/lib/models/preferences.ts +88 -0
- package/src/shared/lib/models/provider-models.ts +8 -1
- package/src/shared/lib/omp/session/stream-state.server.ts +2 -3
- package/src/shared/lib/omp/subagent/label.test.ts +94 -0
- package/src/shared/lib/omp/subagent/label.ts +84 -0
- package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
- package/src/shared/lib/workspace/panel-widths.ts +161 -30
- package/src/shared/lib/workspace/right-panels.ts +50 -11
- package/src/shared/lib/workspace/session-state/store.ts +4 -4
- package/src/shared/lib/workspace/terminal/protocol.ts +222 -0
- package/src/shared/types/chat.ts +38 -65
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/models.ts +101 -0
- package/src/shared/types/omp/agent.ts +12 -0
- package/src/shared/types/omp/subagent.ts +4 -0
- package/src/shared/types/settings/command.ts +13 -0
- package/src/shared/types/terminal.ts +17 -15
- package/src/shared/types/workspace.ts +2 -2
- package/dist/client/static/css/index.5c0447cefe.css +0 -2
- package/dist/client/static/js/async/g.d1c2fe09aa.js +0 -2
- package/dist/client/static/js/async/x4.69ec69c61b.js +0 -6
- package/dist/client/static/js/index.dc5f5fabe8.js +0 -33
- 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/{5b.0d6ae50fe8.js.LICENSE.txt → 5b.a5ccbc19dc.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.d1c2fe09aa.js.LICENSE.txt → g.a06ca3b3e9.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.0896dab823.js.LICENSE.txt → m.c038c71b9f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.dc5f5fabe8.js.LICENSE.txt → index.6e065017eb.js.LICENSE.txt} +0 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Which shell the PTY runtime launches, with which argv, and with which
|
|
8
|
+
* environment.
|
|
9
|
+
*
|
|
10
|
+
* Resolution is a pure function of the environment plus an existence probe, so
|
|
11
|
+
* the preference order stays testable without spawning anything: the user's
|
|
12
|
+
* `$SHELL` first, then the platform's usual suspects.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const POSIX_FALLBACKS = ['/bin/zsh', '/bin/bash', '/bin/sh'];
|
|
16
|
+
|
|
17
|
+
/** Candidate executables, most preferred first. */
|
|
18
|
+
export function shellCandidates(
|
|
19
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
20
|
+
platform: NodeJS.Platform = process.platform,
|
|
21
|
+
): string[] {
|
|
22
|
+
if (platform === 'win32') {
|
|
23
|
+
const comspec = env.COMSPEC?.trim();
|
|
24
|
+
return comspec ? [comspec] : ['cmd.exe'];
|
|
25
|
+
}
|
|
26
|
+
const candidates: string[] = [];
|
|
27
|
+
const preferred = env.SHELL?.trim();
|
|
28
|
+
// Only an absolute $SHELL is trusted: a bare name would be resolved against
|
|
29
|
+
// PATH at spawn time, which is not the login shell the user configured.
|
|
30
|
+
if (preferred && preferred.startsWith('/')) candidates.push(preferred);
|
|
31
|
+
for (const fallback of POSIX_FALLBACKS) {
|
|
32
|
+
if (!candidates.includes(fallback)) candidates.push(fallback);
|
|
33
|
+
}
|
|
34
|
+
return candidates;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `-i -l` for POSIX shells: interactive (prompt, line editing, job control —
|
|
39
|
+
* job control is what makes Ctrl+Z/`fg` work at all) and login (the user's
|
|
40
|
+
* profile, so PATH and aliases match a normal terminal). cmd.exe takes neither.
|
|
41
|
+
*/
|
|
42
|
+
export function shellArgs(platform: NodeJS.Platform = process.platform): string[] {
|
|
43
|
+
return platform === 'win32' ? [] : ['-i', '-l'];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Full argv for the PTY child.
|
|
48
|
+
*
|
|
49
|
+
* POSIX shells are launched through a one-line `/bin/sh` shim that re-acquires
|
|
50
|
+
* the PTY as its controlling terminal before `exec`-ing the real shell:
|
|
51
|
+
*
|
|
52
|
+
* T=$(tty); exec <shell> -i -l <"$T" >"$T" 2>&1
|
|
53
|
+
*
|
|
54
|
+
* This is required, not cosmetic. The runtime must spawn with
|
|
55
|
+
* `detached: true` — without it the child lands in the *server's* process
|
|
56
|
+
* group, where a group signal would take the chamber down and `SIGWINCH`
|
|
57
|
+
* cannot be addressed to the shell alone. But `setsid()` also drops the
|
|
58
|
+
* controlling terminal, and a shell with no controlling tty silently disables
|
|
59
|
+
* job control (`setopt monitor` fails): Ctrl+Z, `fg` and `jobs` stop working,
|
|
60
|
+
* and typing while a foreground command runs goes to that command instead of
|
|
61
|
+
* the shell. Opening the slave device as stdin/stdout restores it. Verified on
|
|
62
|
+
* macOS: with the shim `tpgid` is the shell's own pid and Ctrl+Z suspends;
|
|
63
|
+
* without it `tpgid` is 0 and Ctrl+Z is swallowed.
|
|
64
|
+
*
|
|
65
|
+
* `exec` keeps the pid, so the shell stays the session leader and group kill
|
|
66
|
+
* still reaches everything it starts. If `tty` fails the shell runs
|
|
67
|
+
* unredirected — stdio is already the PTY, only job control is lost.
|
|
68
|
+
*/
|
|
69
|
+
export function shellLaunch(executable: string, platform: NodeJS.Platform = process.platform): string[] {
|
|
70
|
+
if (platform === 'win32') return [executable];
|
|
71
|
+
const inner = [executable, ...shellArgs(platform)].join(' ');
|
|
72
|
+
return ['/bin/sh', '-c', `T=$(tty 2>/dev/null); [ -n "$T" ] && exec ${inner} <"$T" >"$T" 2>&1; exec ${inner}`];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Environment for the PTY child. `TERM`/`COLORTERM` describe the renderer on
|
|
77
|
+
* the other end of the socket; `COLORFGBG` carries the active chamber theme so
|
|
78
|
+
* prompts that read it (zsh themes, vim) pick readable colors.
|
|
79
|
+
*/
|
|
80
|
+
export function terminalEnv(
|
|
81
|
+
base: Record<string, string | undefined>,
|
|
82
|
+
theme: 'light' | 'dark' = 'dark',
|
|
83
|
+
): Record<string, string | undefined> {
|
|
84
|
+
const env: Record<string, string | undefined> = { ...base };
|
|
85
|
+
// The chamber's own IPC descriptor is host-private and meaningless inside the
|
|
86
|
+
// PTY; an inherited value makes Node CLIs fail to parse their IPC channel.
|
|
87
|
+
delete env.NODE_CHANNEL_FD;
|
|
88
|
+
env.TERM = 'xterm-256color';
|
|
89
|
+
env.COLORTERM = 'truecolor';
|
|
90
|
+
env.COLORFGBG = theme === 'light' ? '0;15' : '15;0';
|
|
91
|
+
return env;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** First candidate that exists on disk, or null when none does. */
|
|
95
|
+
export async function resolveShellExecutable(
|
|
96
|
+
candidates: string[],
|
|
97
|
+
exists: (target: string) => Promise<boolean> = async (target) => Bun.file(target).exists(),
|
|
98
|
+
): Promise<string | null> {
|
|
99
|
+
for (const candidate of candidates) {
|
|
100
|
+
if (await exists(candidate)) return candidate;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
@@ -5,6 +5,7 @@ import { resolveRoot } from '@/server/lib/fs/root';
|
|
|
5
5
|
import { runShell } from '@/server/lib/fs/shell';
|
|
6
6
|
import { fetchFileDiff, fetchGitCommits } from '@/server/lib/fs/git-log';
|
|
7
7
|
import { fetchWorkingFileDiff } from '@/server/lib/fs/git-diff';
|
|
8
|
+
import { gitSyncCount, invalidateRemoteRefs, markRemoteRefsFresh, refreshRemoteRefs } from '@/server/lib/fs/git-sync';
|
|
8
9
|
|
|
9
10
|
const MAX_GIT_DEPTH = 8;
|
|
10
11
|
|
|
@@ -117,7 +118,17 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|
|
117
118
|
});
|
|
118
119
|
}
|
|
119
120
|
|
|
121
|
+
// Ahead/behind are only requested by the git panel (`?sync=1`); the
|
|
122
|
+
// status-only polls (activity bar, file explorer) must not trigger a network
|
|
123
|
+
// fetch, and must not pay for a count they never read.
|
|
124
|
+
const syncRequested = url.searchParams.get('sync') === '1';
|
|
125
|
+
|
|
120
126
|
try {
|
|
127
|
+
// Started before the local probes below so the fetch overlaps them instead
|
|
128
|
+
// of adding up; TTL'd inside the helper, so the panel's 5s poll is not a 5s
|
|
129
|
+
// fetch loop.
|
|
130
|
+
const remoteRefresh = syncRequested ? refreshRemoteRefs(targetDir) : Promise.resolve();
|
|
131
|
+
|
|
121
132
|
// Use --porcelain=v1 -uall so all individual edited/untracked files are listed
|
|
122
133
|
const statusOut = (await runShell('git status --porcelain=v1 -uall', { cwd: targetDir, maxBuffer: 1024 * 1024 })).stdout;
|
|
123
134
|
|
|
@@ -167,18 +178,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|
|
167
178
|
};
|
|
168
179
|
});
|
|
169
180
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
try {
|
|
173
|
-
const syncOut = await runShell(
|
|
174
|
-
'git rev-list --left-right --count HEAD...@{upstream}',
|
|
175
|
-
{ cwd: targetDir, timeout: 8000 }
|
|
176
|
-
);
|
|
177
|
-
const [ahead, behind] = syncOut.stdout.trim().split(/\s+/).map(Number);
|
|
178
|
-
syncCount = { ahead: ahead || 0, behind: behind || 0 };
|
|
179
|
-
} catch {
|
|
180
|
-
// No upstream configured — nothing to sync against.
|
|
181
|
-
}
|
|
181
|
+
await remoteRefresh;
|
|
182
|
+
const syncCount = syncRequested ? await gitSyncCount(targetDir) : undefined;
|
|
182
183
|
|
|
183
184
|
return json({
|
|
184
185
|
changes,
|
|
@@ -262,9 +263,13 @@ export async function action({ request }: ActionFunctionArgs) {
|
|
|
262
263
|
await expectOk(`git checkout -b "${localName}" --track "${branch}"`, targetDir);
|
|
263
264
|
}
|
|
264
265
|
}
|
|
266
|
+
// The counts are per-branch: the refs the previous branch refreshed say
|
|
267
|
+
// nothing about the new one's upstream.
|
|
268
|
+
invalidateRemoteRefs(targetDir);
|
|
265
269
|
} else if (actionType === 'create_branch') {
|
|
266
270
|
const branch = formData.get('branch') as string;
|
|
267
271
|
await expectOk(`git checkout -b "${branch}"`, targetDir);
|
|
272
|
+
invalidateRemoteRefs(targetDir);
|
|
268
273
|
} else if (actionType === 'history' || actionType === 'graph') {
|
|
269
274
|
const limit = parseInt((formData.get('limit') as string) || '50', 10);
|
|
270
275
|
const skip = parseInt((formData.get('skip') as string) || '0', 10);
|
|
@@ -306,11 +311,16 @@ export async function action({ request }: ActionFunctionArgs) {
|
|
|
306
311
|
await expectOk(`git rebase "${hash}"`, targetDir);
|
|
307
312
|
} else if (actionType === 'push') {
|
|
308
313
|
await expectOk('git push', targetDir, 120000);
|
|
314
|
+
markRemoteRefsFresh(targetDir);
|
|
309
315
|
} else if (actionType === 'pull') {
|
|
310
316
|
await expectOk('git pull --ff-only', targetDir, 120000);
|
|
317
|
+
markRemoteRefsFresh(targetDir);
|
|
311
318
|
} else if (actionType === 'sync') {
|
|
312
319
|
await expectOk('git pull --ff-only', targetDir, 120000);
|
|
313
320
|
await expectOk('git push', targetDir, 120000);
|
|
321
|
+
// Both commands already moved the tracking ref; the next poll must not
|
|
322
|
+
// fetch again to see a state it just produced.
|
|
323
|
+
markRemoteRefsFresh(targetDir);
|
|
314
324
|
}
|
|
315
325
|
return json({ success: true });
|
|
316
326
|
} catch (error: any) {
|
|
@@ -10,6 +10,7 @@ import { foldersBindings } from '@/server/routes/folders';
|
|
|
10
10
|
import { filesBindings } from '@/server/routes/files';
|
|
11
11
|
import { telemetryBindings } from '@/server/routes/telemetry';
|
|
12
12
|
import { terminalBindings } from '@/server/routes/terminal';
|
|
13
|
+
import { terminalWsRoutes } from '@/server/routes/terminal/ws';
|
|
13
14
|
import { browserBindings } from '@/server/routes/browser';
|
|
14
15
|
import { modelsBindings } from '@/server/routes/models';
|
|
15
16
|
import { ompBindings } from '@/server/routes/omp';
|
|
@@ -35,4 +36,4 @@ const allBindings: HandlerBinding[] = [
|
|
|
35
36
|
...wellKnownBindings,
|
|
36
37
|
];
|
|
37
38
|
|
|
38
|
-
export const apiRoutes = mountBindings(new Elysia(), allBindings).use(agentWsRoutes);
|
|
39
|
+
export const apiRoutes = mountBindings(new Elysia(), allBindings).use(agentWsRoutes).use(terminalWsRoutes);
|