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
|
@@ -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.
|
|
@@ -5,6 +5,7 @@ import { getDb } from '@/server/db.server';
|
|
|
5
5
|
import { DEFAULT_COMMANDS_LIST } from '@/client/data/settings/command';
|
|
6
6
|
import { isMockMode } from '@/server/mock.server';
|
|
7
7
|
import type { CommandItem } from '@/shared/types';
|
|
8
|
+
import { isRecord } from '@/shared/lib/util/guards';
|
|
8
9
|
import { createSettingsListStore } from '@/server/lib/db/settings-store';
|
|
9
10
|
import { runUtilityCommand } from '@/server/lib/omp/rpc/utility';
|
|
10
11
|
|
|
@@ -16,27 +17,67 @@ const commandsStore = createSettingsListStore<CommandItem>({
|
|
|
16
17
|
plural: 'commands',
|
|
17
18
|
});
|
|
18
19
|
|
|
19
|
-
/**
|
|
20
|
+
/** Command sources the chamber surfaces in the composer (mcp_prompt is not user-typable). */
|
|
21
|
+
const AGENT_COMMAND_SOURCES: Record<string, true> = {
|
|
22
|
+
builtin: true,
|
|
23
|
+
skill: true,
|
|
24
|
+
custom: true,
|
|
25
|
+
extension: true,
|
|
26
|
+
file: true,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Live slash commands from the agent's `get_available_commands`, mapped into
|
|
31
|
+
* the chamber's CommandItem. Aliases, subcommands and the argument hint ride
|
|
32
|
+
* along: they are what the composer's `/` popup needs to behave like omp's own
|
|
33
|
+
* editor (`/models` completing to the alias, `/fast ` offering on|off|status).
|
|
34
|
+
* Anything malformed is dropped rather than trusted.
|
|
35
|
+
*/
|
|
36
|
+
function toLiveCommandItem(value: unknown): CommandItem | null {
|
|
37
|
+
if (!isRecord(value)) return null;
|
|
38
|
+
const name = typeof value.name === 'string' ? value.name : '';
|
|
39
|
+
if (!name) return null;
|
|
40
|
+
if (typeof value.source !== 'string' || !AGENT_COMMAND_SOURCES[value.source]) return null;
|
|
41
|
+
|
|
42
|
+
const aliases = Array.isArray(value.aliases)
|
|
43
|
+
? value.aliases.filter((alias): alias is string => typeof alias === 'string' && alias.length > 0)
|
|
44
|
+
: [];
|
|
45
|
+
|
|
46
|
+
const hint = isRecord(value.input) && typeof value.input.hint === 'string' ? value.input.hint : undefined;
|
|
47
|
+
|
|
48
|
+
const subcommands = Array.isArray(value.subcommands)
|
|
49
|
+
? value.subcommands.flatMap((sub) => {
|
|
50
|
+
if (!isRecord(sub) || typeof sub.name !== 'string' || !sub.name) return [];
|
|
51
|
+
return [{
|
|
52
|
+
name: sub.name,
|
|
53
|
+
...(typeof sub.description === 'string' ? { description: sub.description } : {}),
|
|
54
|
+
...(typeof sub.usage === 'string' ? { usage: sub.usage } : {}),
|
|
55
|
+
}];
|
|
56
|
+
})
|
|
57
|
+
: [];
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
id: `omp-live-${name}`,
|
|
61
|
+
name,
|
|
62
|
+
description: typeof value.description === 'string' ? value.description : '',
|
|
63
|
+
scope: 'user',
|
|
64
|
+
template: `/${name}`,
|
|
65
|
+
isBuiltIn: true,
|
|
66
|
+
...(aliases.length ? { aliases } : {}),
|
|
67
|
+
...(subcommands.length ? { subcommands } : {}),
|
|
68
|
+
...(hint ? { inputHint: hint } : {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Read live agent commands (builtin/skill/custom/extension/file sources) via RPC. */
|
|
20
73
|
async function loadAgentCommands(): Promise<CommandItem[]> {
|
|
21
74
|
try {
|
|
22
75
|
const data = await runUtilityCommand<{ commands?: unknown }>({ type: 'get_available_commands' }, 30_000);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const command = c as { name?: string; description?: string; source?: string };
|
|
29
|
-
const name = typeof command.name === 'string' ? command.name : '';
|
|
30
|
-
return {
|
|
31
|
-
id: `omp-live-${name}`,
|
|
32
|
-
name,
|
|
33
|
-
description: typeof command.description === 'string' ? command.description : '',
|
|
34
|
-
scope: 'user' as const,
|
|
35
|
-
template: `/${name}`,
|
|
36
|
-
isBuiltIn: true,
|
|
37
|
-
};
|
|
38
|
-
})
|
|
39
|
-
.filter((c) => c.name.length > 0);
|
|
76
|
+
if (!Array.isArray(data.commands)) return [];
|
|
77
|
+
return data.commands.flatMap((command) => {
|
|
78
|
+
const item = toLiveCommandItem(command);
|
|
79
|
+
return item ? [item] : [];
|
|
80
|
+
});
|
|
40
81
|
} catch {
|
|
41
82
|
return [];
|
|
42
83
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Terminal WebSocket: the only transport between a terminal panel and its PTY.
|
|
8
|
+
*
|
|
9
|
+
* Text frames are JSON control, binary frames are raw PTY bytes in both
|
|
10
|
+
* directions. The client's first frame is `attach`, which also creates the
|
|
11
|
+
* shell when the id is new — one round trip, and no separate create endpoint to
|
|
12
|
+
* race against.
|
|
13
|
+
*
|
|
14
|
+
* The upgrade is origin-checked. Browsers happily open a WebSocket to
|
|
15
|
+
* `localhost` from any page, and there is no preflight to stop them, so a
|
|
16
|
+
* cross-origin page could otherwise drive a shell on this machine.
|
|
17
|
+
*
|
|
18
|
+
* Per-connection state lives on `ws.data`, not in a `WeakMap` keyed by the `ws`
|
|
19
|
+
* object: Elysia hands a *different* wrapper to `message`/`close` than the one
|
|
20
|
+
* `open` received (verified on 1.4.30), so a WeakMap lookup finds nothing and
|
|
21
|
+
* every frame is silently dropped. `ws.data` is the same object throughout.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { Elysia, t } from 'elysia';
|
|
25
|
+
import {
|
|
26
|
+
attachTerminal,
|
|
27
|
+
closeTerminal,
|
|
28
|
+
detachTerminal,
|
|
29
|
+
resizeTerminalDebounced,
|
|
30
|
+
writeTerminalInput,
|
|
31
|
+
} from '@/server/lib/terminal/runtime.server';
|
|
32
|
+
import { TerminalRuntimeError, type TerminalViewer } from '@/server/lib/terminal/session.server';
|
|
33
|
+
import {
|
|
34
|
+
decodeClientFrame,
|
|
35
|
+
encodeServerFrame,
|
|
36
|
+
isValidTerminalId,
|
|
37
|
+
} from '@/shared/lib/workspace/terminal/protocol';
|
|
38
|
+
|
|
39
|
+
interface Attachment {
|
|
40
|
+
id: string;
|
|
41
|
+
viewer: TerminalViewer;
|
|
42
|
+
/** One attach per socket; a duplicate would replay the scrollback twice. */
|
|
43
|
+
attached: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Elysia's ws context, narrowed to the per-connection store this route adds. */
|
|
47
|
+
type TerminalWsData = { params: { terminalId: string }; terminal?: Attachment };
|
|
48
|
+
|
|
49
|
+
export const terminalWsRoutes = new Elysia({ prefix: '/api/terminal' }).ws('/:terminalId/ws', {
|
|
50
|
+
params: t.Object({ terminalId: t.String() }),
|
|
51
|
+
|
|
52
|
+
beforeHandle({ request, status }) {
|
|
53
|
+
if (!isSameOriginUpgrade(request)) return status(403, 'Cross-origin terminal upgrade rejected');
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
open(ws) {
|
|
57
|
+
const data = ws.data as unknown as TerminalWsData;
|
|
58
|
+
const id = data.params.terminalId;
|
|
59
|
+
if (!isValidTerminalId(id)) {
|
|
60
|
+
ws.close();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
data.terminal = {
|
|
64
|
+
id,
|
|
65
|
+
attached: false,
|
|
66
|
+
viewer: {
|
|
67
|
+
send(frame) {
|
|
68
|
+
try {
|
|
69
|
+
ws.send(frame);
|
|
70
|
+
} catch {
|
|
71
|
+
// Socket closed mid-send; the close handler detaches.
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
async message(ws, raw) {
|
|
79
|
+
const attachment = (ws.data as unknown as TerminalWsData).terminal;
|
|
80
|
+
if (!attachment) return;
|
|
81
|
+
|
|
82
|
+
// Raw PTY input (keystrokes, paste, mouse reports) — never JSON-escaped.
|
|
83
|
+
if (raw instanceof Uint8Array) {
|
|
84
|
+
writeTerminalInput(attachment.id, raw);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const frame = decodeClientFrame(raw);
|
|
89
|
+
if (!frame) return;
|
|
90
|
+
|
|
91
|
+
if (frame.t === 'resize') {
|
|
92
|
+
resizeTerminalDebounced(attachment.id, frame.cols, frame.rows);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (frame.t === 'close') {
|
|
97
|
+
closeTerminal(attachment.id);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (attachment.attached) return;
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const { snapshot, replay } = await attachTerminal(attachment.viewer, {
|
|
105
|
+
id: attachment.id,
|
|
106
|
+
cols: frame.cols,
|
|
107
|
+
rows: frame.rows,
|
|
108
|
+
root: frame.root,
|
|
109
|
+
repo: frame.repo,
|
|
110
|
+
theme: frame.theme,
|
|
111
|
+
});
|
|
112
|
+
attachment.attached = true;
|
|
113
|
+
ws.send(encodeServerFrame({ t: 'ready', ...snapshot, replayBytes: replay.length }));
|
|
114
|
+
// Ordering is safe without sequence numbers: attach captured the history
|
|
115
|
+
// and registered this viewer in one synchronous block, so nothing can be
|
|
116
|
+
// delivered between this frame and the replay that follows it.
|
|
117
|
+
if (replay.length > 0) ws.send(replay);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const code = error instanceof TerminalRuntimeError ? error.code : 'unknown';
|
|
120
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
121
|
+
ws.send(encodeServerFrame({ t: 'error', code, message }));
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
close(ws) {
|
|
126
|
+
const data = ws.data as unknown as TerminalWsData;
|
|
127
|
+
const attachment = data.terminal;
|
|
128
|
+
if (!attachment) return;
|
|
129
|
+
data.terminal = undefined;
|
|
130
|
+
detachTerminal(attachment.viewer, attachment.id);
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A WebSocket upgrade carries the page's `Origin`, and no preflight protects
|
|
136
|
+
* it. Requests without one (curl, tests) are allowed: they are not a browser
|
|
137
|
+
* being tricked into dialing localhost.
|
|
138
|
+
*/
|
|
139
|
+
function isSameOriginUpgrade(request: Request): boolean {
|
|
140
|
+
const origin = request.headers.get('origin');
|
|
141
|
+
if (!origin) return true;
|
|
142
|
+
const host = request.headers.get('host');
|
|
143
|
+
if (!host) return false;
|
|
144
|
+
try {
|
|
145
|
+
return new URL(origin).host === host;
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -56,8 +56,13 @@ export function toFilePickItems(files: FsFileEntry[]): ComposerPickItem[] {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
|
-
* Combine
|
|
60
|
-
*
|
|
59
|
+
* Combine live command items (`/name`, including omp's own `skill:<name>`
|
|
60
|
+
* entries) with the chamber's skill list. Commands win: a skill whose plain or
|
|
61
|
+
* `skill:`-namespaced name a command already covers is skipped, so the two
|
|
62
|
+
* sources never double-list the same skill.
|
|
63
|
+
*
|
|
64
|
+
* The description carries the argument hint exactly as oh-my-pi renders it
|
|
65
|
+
* (`[on|off|status] - Toggle fast mode`) so the popup matches the CLI.
|
|
61
66
|
*/
|
|
62
67
|
export function mergeCommandAndSkillItems(commands: CommandItem[], skills: SkillItem[]): ComposerPickItem[] {
|
|
63
68
|
const result: ComposerPickItem[] = [];
|
|
@@ -67,13 +72,17 @@ export function mergeCommandAndSkillItems(commands: CommandItem[], skills: Skill
|
|
|
67
72
|
const key = command.name.toLowerCase();
|
|
68
73
|
if (seen.has(key)) continue;
|
|
69
74
|
seen.add(key);
|
|
75
|
+
const description = command.description ?? '';
|
|
70
76
|
result.push({
|
|
71
77
|
id: `command-${command.name}`,
|
|
72
78
|
name: command.name,
|
|
73
|
-
description: command.description,
|
|
74
|
-
kind: 'command',
|
|
75
|
-
source: 'command',
|
|
79
|
+
description: command.inputHint ? (description ? `${command.inputHint} - ${description}` : command.inputHint) : description,
|
|
80
|
+
kind: command.name.toLowerCase().startsWith('skill:') ? 'skill' : 'command',
|
|
81
|
+
source: command.name.toLowerCase().startsWith('skill:') ? 'skill' : 'command',
|
|
76
82
|
token: `/${command.name}`,
|
|
83
|
+
...(command.aliases?.length ? { aliases: command.aliases } : {}),
|
|
84
|
+
...(command.subcommands?.length ? { subcommands: command.subcommands } : {}),
|
|
85
|
+
...(command.inputHint ? { inputHint: command.inputHint } : {}),
|
|
77
86
|
});
|
|
78
87
|
}
|
|
79
88
|
|
|
@@ -84,11 +93,11 @@ export function mergeCommandAndSkillItems(commands: CommandItem[], skills: Skill
|
|
|
84
93
|
seen.add(prefixed);
|
|
85
94
|
result.push({
|
|
86
95
|
id: `skill-${skill.name}`,
|
|
87
|
-
name:
|
|
96
|
+
name: prefixed,
|
|
88
97
|
description: skill.description,
|
|
89
|
-
kind: '
|
|
98
|
+
kind: 'skill',
|
|
90
99
|
source: 'skill',
|
|
91
|
-
token:
|
|
100
|
+
token: `/${prefixed}`,
|
|
92
101
|
});
|
|
93
102
|
}
|
|
94
103
|
|