ompchamber 0.8.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.5c0447cefe.css → index.e880517fcd.css} +1 -1
- 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.dc5f5fabe8.js → index.0de15c5e17.js} +10 -10
- package/package.json +2 -1
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +1 -1
- 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/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/index.tsx +4 -2
- 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 +19 -2
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +1 -1
- package/src/client/hooks/chat/timeline/actions.ts +23 -25
- package/src/client/hooks/chat/timeline/index.ts +12 -20
- package/src/client/hooks/chat/timeline/queue.ts +110 -46
- 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/omp/rpc/manager.ts +58 -3
- package/src/server/lib/omp/rpc/session-commands.ts +6 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +3 -3
- 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/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/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 +12 -0
- package/src/shared/types/terminal.ts +17 -15
- package/src/shared/types/workspace.ts +2 -2
- package/dist/client/static/js/async/g.d1c2fe09aa.js +0 -2
- package/dist/client/static/js/async/x4.69ec69c61b.js +0 -6
- 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/{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.0de15c5e17.js.LICENSE.txt} +0 -0
|
@@ -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);
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { actionBindings, bindingsFor, type HandlerBinding } from '@/server/lib/route-adapter';
|
|
2
2
|
import * as sessionsList from '@/server/routes/sessions/list';
|
|
3
3
|
import * as sessionsFolder from '@/server/routes/sessions/folder';
|
|
4
|
+
import * as sessionsQueue from '@/server/routes/sessions/queue';
|
|
4
5
|
import {
|
|
5
6
|
archiveSession,
|
|
6
|
-
getQueue,
|
|
7
7
|
getSessionState,
|
|
8
8
|
markSeen,
|
|
9
|
-
putQueue,
|
|
10
9
|
putSessionState,
|
|
11
10
|
renameSession,
|
|
12
11
|
} from '@/server/routes/sessions/session';
|
|
@@ -16,7 +15,16 @@ export const sessionsBindings: HandlerBinding[] = [
|
|
|
16
15
|
...bindingsFor(sessionsList, '/api/sessions/list'),
|
|
17
16
|
...bindingsFor(sessionsFolder, '/api/sessions/:sessionId'),
|
|
18
17
|
...actionBindings(archiveSession, '/api/sessions/:sessionId/archive'),
|
|
19
|
-
|
|
18
|
+
// Per-item queue routes. `actionBindings` mounts ALL four mutating verbs on
|
|
19
|
+
// one path, and Elysia lets a later registration override an earlier one —
|
|
20
|
+
// mounting reorder on the same `/queue` path that way would clobber the
|
|
21
|
+
// add/GET bindings with its 405 GET fallback. Only reorder gets an explicit
|
|
22
|
+
// PUT-only binding; add carries the shared GET loader.
|
|
23
|
+
...actionBindings(sessionsQueue.addQueueItem, '/api/sessions/:sessionId/queue', sessionsQueue.getQueue),
|
|
24
|
+
...actionBindings(sessionsQueue.editQueueItem, '/api/sessions/:sessionId/queue/:itemId'),
|
|
25
|
+
{ method: 'PUT', path: '/api/sessions/:sessionId/queue', handler: sessionsQueue.reorderQueueItems },
|
|
26
|
+
{ method: 'DELETE', path: '/api/sessions/:sessionId/queue/:itemId', handler: sessionsQueue.removeQueueItem },
|
|
27
|
+
...actionBindings(sessionsQueue.nudgeQueueDelivery, '/api/sessions/:sessionId/queue/deliver'),
|
|
20
28
|
...actionBindings(renameSession, '/api/sessions/:sessionId/rename'),
|
|
21
29
|
...actionBindings(putSessionState, '/api/sessions/:sessionId/state', getSessionState),
|
|
22
30
|
...actionBindings(markSeen, '/api/sessions/:sessionId/stream-seen'),
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Per-item follow-up queue routes (`queued_messages` table). The client panel
|
|
8
|
+
* is a VIEW: every mutation goes through one of these endpoints and returns
|
|
9
|
+
* the canonical queue, so the client never ships a full-list replacement
|
|
10
|
+
* (that shape is what let a reload wipe the table and a session switch copy
|
|
11
|
+
* one session's queue into another).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { json } from '@/server/lib/remix-compat';
|
|
15
|
+
import type { ActionFunctionArgs, LoaderFunctionArgs } from '@/server/lib/remix-compat';
|
|
16
|
+
import { requireParam } from '@/server/lib/route-adapter';
|
|
17
|
+
import {
|
|
18
|
+
appendQueueItem,
|
|
19
|
+
deleteQueueItem,
|
|
20
|
+
listQueue,
|
|
21
|
+
patchQueueItem,
|
|
22
|
+
reorderQueue,
|
|
23
|
+
} from '@/server/lib/queue/store.server';
|
|
24
|
+
import { getRpcSession } from '@/server/lib/omp/rpc/manager';
|
|
25
|
+
import { scheduleQueueDelivery } from '@/server/lib/queue/delivery.server';
|
|
26
|
+
|
|
27
|
+
/** GET — the session's queued follow-ups in delivery order. */
|
|
28
|
+
export async function getQueue({ params }: LoaderFunctionArgs) {
|
|
29
|
+
const sessionId = requireParam(params, 'sessionId');
|
|
30
|
+
if (!sessionId) return json({ error: 'Session ID is required' }, { status: 400 });
|
|
31
|
+
return json({ queue: await listQueue(sessionId) });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* POST — append one queued item. Body: `{ text, attachments?, model? }`.
|
|
36
|
+
* Returns the canonical queue.
|
|
37
|
+
*/
|
|
38
|
+
export async function addQueueItem({ request, params }: ActionFunctionArgs) {
|
|
39
|
+
const sessionId = requireParam(params, 'sessionId');
|
|
40
|
+
if (!sessionId) return json({ error: 'Session ID is required' }, { status: 400 });
|
|
41
|
+
if (request.method !== 'POST') return json({ error: 'Method not allowed' }, { status: 405 });
|
|
42
|
+
|
|
43
|
+
const body = (await request.json().catch(() => null)) as Record<string, unknown> | null;
|
|
44
|
+
if (!body || typeof body.text !== 'string' || (!body.text.trim() && !Array.isArray(body.attachments))) {
|
|
45
|
+
return json({ error: 'Invalid body: expected { text: string, attachments?, model? }' }, { status: 400 });
|
|
46
|
+
}
|
|
47
|
+
const queue = await appendQueueItem(sessionId, {
|
|
48
|
+
text: body.text,
|
|
49
|
+
attachments: body.attachments,
|
|
50
|
+
model: body.model,
|
|
51
|
+
});
|
|
52
|
+
return json({ queue });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* PATCH — edit one item's text and/or model snapshot. Body: `{ text?, model? }`.
|
|
57
|
+
* Returns the canonical queue; 404 when the id is unknown or already claimed.
|
|
58
|
+
*/
|
|
59
|
+
export async function editQueueItem({ request, params }: ActionFunctionArgs) {
|
|
60
|
+
const sessionId = requireParam(params, 'sessionId');
|
|
61
|
+
if (!sessionId) return json({ error: 'Session ID is required' }, { status: 400 });
|
|
62
|
+
if (request.method !== 'PATCH') return json({ error: 'Method not allowed' }, { status: 405 });
|
|
63
|
+
|
|
64
|
+
const itemId = params.itemId;
|
|
65
|
+
if (!itemId) return json({ error: 'Item ID is required' }, { status: 400 });
|
|
66
|
+
const body = (await request.json().catch(() => null)) as Record<string, unknown> | null;
|
|
67
|
+
if (!body) return json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
68
|
+
|
|
69
|
+
const queue = await patchQueueItem(sessionId, itemId, { text: body.text, model: body.model });
|
|
70
|
+
if (!queue) return json({ error: 'Queue item not found' }, { status: 404 });
|
|
71
|
+
return json({ queue });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* DELETE — remove one item by id. 404 when it was already claimed for
|
|
76
|
+
* delivery or never existed (both leave nothing to remove).
|
|
77
|
+
*/
|
|
78
|
+
export async function removeQueueItem({ request, params }: ActionFunctionArgs) {
|
|
79
|
+
const sessionId = requireParam(params, 'sessionId');
|
|
80
|
+
if (!sessionId) return json({ error: 'Session ID is required' }, { status: 400 });
|
|
81
|
+
if (request.method !== 'DELETE') return json({ error: 'Method not allowed' }, { status: 405 });
|
|
82
|
+
|
|
83
|
+
const itemId = params.itemId;
|
|
84
|
+
if (!itemId) return json({ error: 'Item ID is required' }, { status: 400 });
|
|
85
|
+
const removed = await deleteQueueItem(sessionId, itemId);
|
|
86
|
+
if (!removed) return json({ error: 'Queue item not found' }, { status: 404 });
|
|
87
|
+
return json({ success: true });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* PUT — reorder the queue to exactly `{ orderedIds: string[] }` (a drag in the
|
|
92
|
+
* panel ships ids only, never item payloads). Returns the canonical queue.
|
|
93
|
+
*/
|
|
94
|
+
export async function reorderQueueItems({ request, params }: ActionFunctionArgs) {
|
|
95
|
+
const sessionId = requireParam(params, 'sessionId');
|
|
96
|
+
if (!sessionId) return json({ error: 'Session ID is required' }, { status: 400 });
|
|
97
|
+
if (request.method !== 'PUT') return json({ error: 'Method not allowed' }, { status: 405 });
|
|
98
|
+
|
|
99
|
+
const body = (await request.json().catch(() => null)) as { orderedIds?: unknown } | null;
|
|
100
|
+
if (!body || !Array.isArray(body.orderedIds) || body.orderedIds.some((id) => typeof id !== 'string')) {
|
|
101
|
+
return json({ error: 'Invalid body: expected { orderedIds: string[] }' }, { status: 400 });
|
|
102
|
+
}
|
|
103
|
+
return json({ queue: await reorderQueue(sessionId, body.orderedIds) });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* POST /queue/deliver — client nudge: a tab that (re)opens a session offers
|
|
108
|
+
* the server a delivery slot. The server-side claim makes this a no-op when a
|
|
109
|
+
* run is active or the head was already claimed; an idle session with queued
|
|
110
|
+
* items gets its head delivered exactly once.
|
|
111
|
+
*/
|
|
112
|
+
export async function nudgeQueueDelivery({ request, params }: ActionFunctionArgs) {
|
|
113
|
+
const sessionId = requireParam(params, 'sessionId');
|
|
114
|
+
if (!sessionId) return json({ error: 'Session ID is required' }, { status: 400 });
|
|
115
|
+
if (request.method !== 'POST') return json({ error: 'Method not allowed' }, { status: 405 });
|
|
116
|
+
|
|
117
|
+
const session = getRpcSession(sessionId);
|
|
118
|
+
if (!session?.isAlive()) return json({ success: true, delivered: false, reason: 'session-not-live' });
|
|
119
|
+
scheduleQueueDelivery(session);
|
|
120
|
+
return json({ success: true });
|
|
121
|
+
}
|
|
@@ -2,15 +2,12 @@ import { json } from '@/server/lib/remix-compat';
|
|
|
2
2
|
import type { ActionFunctionArgs, LoaderFunctionArgs } from '@/server/lib/remix-compat';
|
|
3
3
|
import { methodNotAllowed } from '@/server/lib/route-adapter';
|
|
4
4
|
import { getDb } from '@/server/db.server';
|
|
5
|
-
import { withTransaction } from '@/shared/lib/db/transaction.server';
|
|
6
|
-
import { isApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
7
5
|
import { markStreamSeen } from '@/shared/lib/omp/session/stream-state.server';
|
|
8
6
|
import { isMockMode } from '@/server/mock.server';
|
|
9
7
|
import { getRpcSession } from '@/server/lib/omp/rpc/manager';
|
|
10
8
|
import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
|
|
11
9
|
import { resolveSessionFileOr404 } from '@/server/lib/omp/session/locator';
|
|
12
10
|
import { setSessionTitle } from '@/server/lib/omp/session/title-slot';
|
|
13
|
-
import type { QueuedMessage, QueuedMessageModel } from '@/shared/types/chat';
|
|
14
11
|
|
|
15
12
|
/**
|
|
16
13
|
* POST /api/sessions/:sessionId/archive — toggle archive state for a session.
|
|
@@ -46,134 +43,6 @@ export async function archiveSession({ request, params }: ActionFunctionArgs) {
|
|
|
46
43
|
return json({ success: true, sessionId, archived });
|
|
47
44
|
}
|
|
48
45
|
|
|
49
|
-
/**
|
|
50
|
-
* SQLite-backed queue for a session (`queued_messages` table). Rows carry a
|
|
51
|
-
* model snapshot (provider, model_id, thinking_level, access_mode) so the
|
|
52
|
-
* auto-delivery replays the exact settings the item was queued with, instead
|
|
53
|
-
* of whatever the session runs with at delivery time.
|
|
54
|
-
*/
|
|
55
|
-
|
|
56
|
-
interface QueueRow {
|
|
57
|
-
id: string;
|
|
58
|
-
session_id: string;
|
|
59
|
-
position: number;
|
|
60
|
-
message: string;
|
|
61
|
-
attachments: string;
|
|
62
|
-
provider: string | null;
|
|
63
|
-
model_id: string | null;
|
|
64
|
-
thinking_level: string | null;
|
|
65
|
-
access_mode: string | null;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function rowToQueuedMessage(row: QueueRow): QueuedMessage {
|
|
69
|
-
let attachments: unknown = [];
|
|
70
|
-
try {
|
|
71
|
-
attachments = JSON.parse(row.attachments);
|
|
72
|
-
} catch {
|
|
73
|
-
attachments = [];
|
|
74
|
-
}
|
|
75
|
-
const model: QueuedMessageModel | null =
|
|
76
|
-
row.provider && row.model_id
|
|
77
|
-
? {
|
|
78
|
-
provider: row.provider,
|
|
79
|
-
modelId: row.model_id,
|
|
80
|
-
thinkingLevel: row.thinking_level ?? 'auto',
|
|
81
|
-
accessMode: isApprovalMode(row.access_mode) ? row.access_mode : 'always-ask',
|
|
82
|
-
}
|
|
83
|
-
: null;
|
|
84
|
-
return {
|
|
85
|
-
id: row.id,
|
|
86
|
-
text: row.message,
|
|
87
|
-
attachments: Array.isArray(attachments) ? (attachments as QueuedMessage['attachments']) : [],
|
|
88
|
-
model,
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** GET — the session's queued follow-ups in delivery order. */
|
|
93
|
-
export async function getQueue({ params }: LoaderFunctionArgs) {
|
|
94
|
-
const { sessionId } = params;
|
|
95
|
-
if (!sessionId) return json({ error: 'Session ID is required' }, { status: 400 });
|
|
96
|
-
|
|
97
|
-
const db = await getDb();
|
|
98
|
-
const rows = (await db.all(
|
|
99
|
-
'SELECT * FROM queued_messages WHERE session_id = ? ORDER BY position ASC',
|
|
100
|
-
[sessionId],
|
|
101
|
-
)) as QueueRow[];
|
|
102
|
-
return json({ queue: rows.map(rowToQueuedMessage) });
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
interface QueuePayload {
|
|
106
|
-
id?: unknown;
|
|
107
|
-
text?: unknown;
|
|
108
|
-
attachments?: unknown;
|
|
109
|
-
model?: unknown;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function normalizeModel(raw: unknown): QueuedMessageModel | null {
|
|
113
|
-
if (!raw || typeof raw !== 'object') return null;
|
|
114
|
-
const m = raw as Record<string, unknown>;
|
|
115
|
-
if (typeof m.provider !== 'string' || typeof m.modelId !== 'string') return null;
|
|
116
|
-
return {
|
|
117
|
-
provider: m.provider,
|
|
118
|
-
modelId: m.modelId,
|
|
119
|
-
thinkingLevel: typeof m.thinkingLevel === 'string' ? m.thinkingLevel : 'auto',
|
|
120
|
-
accessMode: isApprovalMode(m.accessMode) ? m.accessMode : 'always-ask',
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* PUT — replace the session's whole queue with the posted list (the client
|
|
126
|
-
* queue panel is the source of truth: reorder, edit, delete all ship the full
|
|
127
|
-
* remaining list back). Ids are preserved so in-flight mirrors and React keys
|
|
128
|
-
* stay stable.
|
|
129
|
-
*/
|
|
130
|
-
export async function putQueue({ request, params }: ActionFunctionArgs) {
|
|
131
|
-
const { sessionId } = params;
|
|
132
|
-
if (!sessionId) return json({ error: 'Session ID is required' }, { status: 400 });
|
|
133
|
-
if (request.method !== 'PUT') {
|
|
134
|
-
return methodNotAllowed({ request, params });
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const body = (await request.json().catch(() => null)) as { queue?: unknown } | null;
|
|
138
|
-
if (!body || !Array.isArray(body.queue)) {
|
|
139
|
-
return json({ error: 'Invalid body: expected { queue: QueuedMessage[] }' }, { status: 400 });
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const db = await getDb();
|
|
143
|
-
const items = body.queue
|
|
144
|
-
.filter((raw): raw is QueuePayload => Boolean(raw) && typeof raw === 'object')
|
|
145
|
-
.map((raw, index) => ({
|
|
146
|
-
id: typeof raw.id === 'string' && raw.id ? raw.id : `queue-${Date.now()}-${index}`,
|
|
147
|
-
text: typeof raw.text === 'string' ? raw.text : '',
|
|
148
|
-
attachments: Array.isArray(raw.attachments) ? JSON.stringify(raw.attachments) : '[]',
|
|
149
|
-
model: normalizeModel(raw.model),
|
|
150
|
-
position: index,
|
|
151
|
-
}));
|
|
152
|
-
|
|
153
|
-
withTransaction(db, () => {
|
|
154
|
-
db.raw.run('DELETE FROM queued_messages WHERE session_id = ?', [sessionId]);
|
|
155
|
-
const insert = db.raw.query(
|
|
156
|
-
`INSERT INTO queued_messages (id, session_id, position, message, attachments, provider, model_id, thinking_level, access_mode)
|
|
157
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
158
|
-
);
|
|
159
|
-
for (const item of items) {
|
|
160
|
-
insert.run(
|
|
161
|
-
item.id,
|
|
162
|
-
sessionId,
|
|
163
|
-
item.position,
|
|
164
|
-
item.text,
|
|
165
|
-
item.attachments,
|
|
166
|
-
item.model?.provider ?? null,
|
|
167
|
-
item.model?.modelId ?? null,
|
|
168
|
-
item.model?.thinkingLevel ?? null,
|
|
169
|
-
item.model?.accessMode ?? null,
|
|
170
|
-
);
|
|
171
|
-
}
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
return json({ success: true });
|
|
175
|
-
}
|
|
176
|
-
|
|
177
46
|
const MAX_SESSION_NAME_LENGTH = 200;
|
|
178
47
|
|
|
179
48
|
/**
|
|
@@ -316,7 +185,7 @@ export async function putSessionState({ request, params }: ActionFunctionArgs) {
|
|
|
316
185
|
|
|
317
186
|
/**
|
|
318
187
|
* POST /api/sessions/:sessionId/stream-seen — acknowledge the session's
|
|
319
|
-
* one-shot stream badge (`finish` / `abort`
|
|
188
|
+
* one-shot stream badge (`finish` / `abort`). Called when the
|
|
320
189
|
* session is opened; deletes the terminal row so the check shows exactly
|
|
321
190
|
* once. A `stream` row (live run) is never deleted — a client acking from a
|
|
322
191
|
* stale status map must not kill the spinner of a running session.
|
|
@@ -21,7 +21,7 @@ async function loadAgentCommands(): Promise<CommandItem[]> {
|
|
|
21
21
|
try {
|
|
22
22
|
const data = await runUtilityCommand<{ commands?: unknown }>({ type: 'get_available_commands' }, 30_000);
|
|
23
23
|
const available = Array.isArray(data.commands) ? data.commands : [];
|
|
24
|
-
const agentSources = new Set(['skill', 'custom', 'extension', 'file']);
|
|
24
|
+
const agentSources = new Set(['builtin', 'skill', 'custom', 'extension', 'file']);
|
|
25
25
|
return available
|
|
26
26
|
.filter((c) => agentSources.has((c as { source?: string })?.source ?? ''))
|
|
27
27
|
.map((c) => {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { bindingsFor, type HandlerBinding } from '@/server/lib/route-adapter';
|
|
2
2
|
import * as terminalRun from '@/server/routes/terminal/run';
|
|
3
|
-
import * as
|
|
3
|
+
import * as terminalSessions from '@/server/routes/terminal/sessions';
|
|
4
4
|
|
|
5
5
|
export const terminalBindings: HandlerBinding[] = [
|
|
6
6
|
...bindingsFor(terminalRun, '/api/terminal/run'),
|
|
7
|
-
...bindingsFor(
|
|
7
|
+
...bindingsFor(terminalSessions, '/api/terminal/sessions'),
|
|
8
8
|
];
|
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import { json, type
|
|
2
|
-
import path from 'path';
|
|
1
|
+
import { json, type LoaderFunctionArgs } from '@/server/lib/remix-compat';
|
|
3
2
|
import { runShell } from '@/server/lib/fs/shell';
|
|
4
3
|
import { resolveRoot } from '@/server/lib/fs/root';
|
|
5
4
|
import { scopeToRepo } from '@/server/lib/fs/repo-scope';
|
|
6
|
-
import { matchCdCommand, resolveCdTarget, resolveTerminalCwd } from '@/server/lib/fs/terminal-cwd';
|
|
7
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Launch context for the terminal header: which directory the shell opens in,
|
|
8
|
+
* the git branch checked out there, and the runtime versions.
|
|
9
|
+
*
|
|
10
|
+
* Command execution used to live here as a one-shot `action`; the PTY socket
|
|
11
|
+
* replaced it, so only the display half remains.
|
|
12
|
+
*/
|
|
8
13
|
export async function loader({ request }: LoaderFunctionArgs) {
|
|
9
14
|
const url = new URL(request.url);
|
|
10
15
|
const rootDir = await resolveRoot(url.searchParams.get('root'), process.cwd());
|
|
@@ -36,100 +41,3 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|
|
36
41
|
relativePath: '.',
|
|
37
42
|
});
|
|
38
43
|
}
|
|
39
|
-
|
|
40
|
-
export async function action({ request }: ActionFunctionArgs) {
|
|
41
|
-
let command = '';
|
|
42
|
-
let requestedCwd = '';
|
|
43
|
-
let requestedRoot = '';
|
|
44
|
-
let requestedRepo = '';
|
|
45
|
-
|
|
46
|
-
const contentType = request.headers.get('content-type') || '';
|
|
47
|
-
if (contentType.includes('application/json')) {
|
|
48
|
-
const body = await request.json();
|
|
49
|
-
command = (body.command || '').trim();
|
|
50
|
-
requestedCwd = (body.cwd || '').trim();
|
|
51
|
-
requestedRoot = (body.root || '').trim();
|
|
52
|
-
requestedRepo = (body.repo || '').trim();
|
|
53
|
-
} else {
|
|
54
|
-
const formData = await request.formData();
|
|
55
|
-
command = ((formData.get('command') as string) || '').trim();
|
|
56
|
-
requestedCwd = ((formData.get('cwd') as string) || '').trim();
|
|
57
|
-
requestedRoot = ((formData.get('root') as string) || '').trim();
|
|
58
|
-
requestedRepo = ((formData.get('repo') as string) || '').trim();
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const baseDir = await resolveRoot(requestedRoot, process.cwd());
|
|
62
|
-
const rootDir = await scopeToRepo(baseDir, requestedRepo);
|
|
63
|
-
const currentDir = await resolveTerminalCwd(rootDir, requestedCwd);
|
|
64
|
-
|
|
65
|
-
if (!command) {
|
|
66
|
-
return json({
|
|
67
|
-
stdout: '',
|
|
68
|
-
stderr: '',
|
|
69
|
-
exitCode: 0,
|
|
70
|
-
durationMs: 0,
|
|
71
|
-
cwd: path.relative(rootDir, currentDir) || '.',
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Handle 'cd' commands directly to track directory state across calls
|
|
76
|
-
const cdTarget = matchCdCommand(command);
|
|
77
|
-
if (cdTarget !== null) {
|
|
78
|
-
if (!cdTarget || cdTarget === '~' || cdTarget === '/') {
|
|
79
|
-
return json({
|
|
80
|
-
stdout: '',
|
|
81
|
-
stderr: '',
|
|
82
|
-
exitCode: 0,
|
|
83
|
-
durationMs: 1,
|
|
84
|
-
cwd: '.',
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const cd = await resolveCdTarget(rootDir, currentDir, cdTarget);
|
|
89
|
-
if (!cd.ok) {
|
|
90
|
-
return json({
|
|
91
|
-
stdout: '',
|
|
92
|
-
stderr: cd.reason === 'above-root'
|
|
93
|
-
? `cd: permission denied: cannot navigate above workspace root`
|
|
94
|
-
: `cd: no such file or directory: ${cdTarget}`,
|
|
95
|
-
exitCode: 1,
|
|
96
|
-
durationMs: 1,
|
|
97
|
-
cwd: path.relative(rootDir, currentDir) || '.',
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
return json({
|
|
102
|
-
stdout: '',
|
|
103
|
-
stderr: '',
|
|
104
|
-
exitCode: 0,
|
|
105
|
-
durationMs: 1,
|
|
106
|
-
cwd: cd.cwd,
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const startTime = Date.now();
|
|
111
|
-
const result = await runShell(command, {
|
|
112
|
-
cwd: currentDir,
|
|
113
|
-
timeout: 30000,
|
|
114
|
-
maxBuffer: 1024 * 1024 * 2,
|
|
115
|
-
env: {
|
|
116
|
-
PAGER: 'cat',
|
|
117
|
-
FORCE_COLOR: '0',
|
|
118
|
-
},
|
|
119
|
-
});
|
|
120
|
-
const durationMs = Date.now() - startTime;
|
|
121
|
-
// Timeout detection uses signalCode (SIGTERM from Bun), not `killed` —
|
|
122
|
-
// Bun 1.4 sets killed=true even for normal non-zero exits.
|
|
123
|
-
const timedOut = result.signalCode !== null;
|
|
124
|
-
const exitCode = timedOut ? 1 : (result.exitCode ?? 1);
|
|
125
|
-
const stderr = result.stderr || (timedOut ? 'Command timed out' : '') || (exitCode === 0 ? '' : result.error?.message || 'Command execution failed');
|
|
126
|
-
|
|
127
|
-
return json({
|
|
128
|
-
stdout: result.stdout,
|
|
129
|
-
stderr,
|
|
130
|
-
exitCode,
|
|
131
|
-
durationMs,
|
|
132
|
-
cwd: path.relative(rootDir, currentDir) || '.',
|
|
133
|
-
...(exitCode === 0 ? {} : { error: result.error?.message || (timedOut ? 'Command timed out' : `Command exited with code ${exitCode}`) }),
|
|
134
|
-
});
|
|
135
|
-
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Live terminal inventory.
|
|
8
|
+
*
|
|
9
|
+
* The panel persists its terminal id per chamber session, so a reload normally
|
|
10
|
+
* reattaches to the shell it already owns. This endpoint covers the case where
|
|
11
|
+
* that id is gone (cleared storage, a second browser, a new tab): the client can
|
|
12
|
+
* see which shells are alive in the active scope and adopt one instead of
|
|
13
|
+
* leaving it to the idle reaper.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { json, type LoaderFunctionArgs } from '@/server/lib/remix-compat';
|
|
17
|
+
import { listTerminals } from '@/server/lib/terminal/runtime.server';
|
|
18
|
+
|
|
19
|
+
export async function loader({ request }: LoaderFunctionArgs) {
|
|
20
|
+
const url = new URL(request.url);
|
|
21
|
+
return json({
|
|
22
|
+
terminals: await listTerminals(url.searchParams.get('root'), url.searchParams.get('repo')),
|
|
23
|
+
});
|
|
24
|
+
}
|