ompchamber 0.7.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/index.html +1 -1
- package/dist/client/static/css/index.e880517fcd.css +2 -0
- package/dist/client/static/js/async/{3y.4c2596f64c.js → 3y.71f2ccf3b6.js} +2 -2
- package/dist/client/static/js/async/{xw.ae67ba06fd.js → 5b.0d6ae50fe8.js} +10 -10
- package/dist/client/static/js/async/5bh.8b2363424f.js +1 -0
- package/dist/client/static/js/async/{vy.bbe114c4e0.js → bi.09688ba75c.js} +1 -2
- package/dist/client/static/js/async/c.797d933882.js +1 -0
- package/dist/client/static/js/async/{dc.6dc4b0a04b.js → dc.856fda6560.js} +2 -2
- package/dist/client/static/js/async/g.a06ca3b3e9.js +2 -0
- package/dist/client/static/js/async/g.a06ca3b3e9.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{m.4b29d34f93.js → m.c038c71b9f.js} +2 -2
- package/dist/client/static/js/async/ma.2c261b04dd.js +2 -0
- package/dist/client/static/js/async/ms.98960af4eb.js +67 -0
- package/dist/client/static/js/async/nam.b5663e919f.js +10 -0
- package/dist/client/static/js/async/nam.b5663e919f.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/ot.84c71b308f.js +2 -0
- package/dist/client/static/js/async/ot.84c71b308f.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{q.542c184fec.js → q.23af2a358f.js} +3 -3
- package/dist/client/static/js/async/{s.413c2a4683.js → s.222ee974f6.js} +2 -2
- package/dist/client/static/js/async/t.4dca0e2ba8.js +1 -0
- package/dist/client/static/js/async/uf.46ad6209fa.js +2 -0
- package/dist/client/static/js/async/xn.6360379f71.js +2 -0
- package/dist/client/static/js/async/xnl.feb0021e50.js +2 -0
- package/dist/client/static/js/async/xnl.feb0021e50.js.LICENSE.txt +12 -0
- package/dist/client/static/js/index.0de15c5e17.js +33 -0
- package/dist/client/static/js/{uy.2e8929ed0c.js → jp.50817896f3.js} +6 -6
- package/package.json +2 -1
- package/src/client/components/common/MarkdownRenderer.tsx +69 -27
- package/src/client/components/common/code-editor/history.test.ts +118 -0
- package/src/client/components/common/code-editor/history.ts +101 -0
- package/src/client/components/common/code-editor/index.tsx +56 -66
- package/src/client/components/common/code-surface/index.tsx +84 -11
- package/src/client/components/common/code-surface/measure.ts +66 -17
- package/src/client/components/common/diagram-viewer/index.tsx +258 -0
- package/src/client/components/common/diagram-viewer/read-block.ts +53 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +2 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +11 -4
- package/src/client/components/layout/session-sidebar/SessionList.tsx +1 -1
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +1 -1
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +1 -1
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +2 -2
- package/src/client/components/settings/LazyModal.tsx +1 -1
- package/src/client/components/settings/Modal.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +11 -4
- package/src/client/components/workspace/chat-timeline/QueueList.tsx +18 -14
- package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +8 -1
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +11 -52
- package/src/client/components/workspace/chat-timeline/chat-input/selection.ts +57 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +124 -108
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/FrameControl.tsx +172 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +155 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/drafts.ts +113 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +92 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Footer.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Header.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Lazy.tsx +8 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/SelectBody.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/index.tsx +16 -10
- package/src/client/components/workspace/chat-timeline/tool-renderers/index.tsx +2 -2
- package/src/client/components/workspace/file-explorer/index.tsx +18 -5
- package/src/client/components/workspace/git-panel/BranchToolbar.tsx +2 -2
- package/src/client/components/workspace/git-panel/index.tsx +5 -1
- package/src/client/components/workspace/terminal-panel/Header.tsx +51 -19
- package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +86 -282
- package/src/client/components/workspace/terminal-panel/index.tsx +76 -50
- package/src/client/components/workspace/terminal-panel/mount.ts +275 -0
- package/src/client/data/theme/terminal.ts +27 -29
- package/src/client/hooks/chat/omp/session-list.tsx +23 -2
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +1 -1
- package/src/client/hooks/chat/timeline/actions.ts +23 -34
- package/src/client/hooks/chat/timeline/ask-frames.ts +102 -0
- package/src/client/hooks/chat/timeline/extension-dialog.ts +12 -9
- package/src/client/hooks/chat/timeline/index.ts +18 -28
- package/src/client/hooks/chat/timeline/prompt-text.ts +35 -0
- package/src/client/hooks/chat/timeline/queue.ts +110 -46
- package/src/client/hooks/chat/timeline/send.ts +13 -27
- package/src/client/hooks/chat/timeline/session-load.ts +48 -106
- package/src/client/hooks/chat/timeline/session-pagination.ts +144 -0
- package/src/client/hooks/editor/use-code-window.ts +118 -0
- package/src/client/hooks/ui/input-required-alert.ts +43 -0
- package/src/client/hooks/ui/notification-sound.ts +65 -0
- package/src/client/hooks/ui/theme.ts +22 -6
- package/src/client/hooks/workspace/terminal/index.ts +217 -0
- package/src/client/hooks/workspace/terminal/socket.ts +227 -0
- package/src/client/tailwind.css +38 -12
- package/src/server/db.server.ts +19 -0
- package/src/server/lib/fs/git-sync.ts +191 -0
- package/src/server/lib/omp/rpc/manager.ts +72 -6
- package/src/server/lib/omp/rpc/session-commands.ts +6 -0
- package/src/server/lib/omp/rpc/session-registry.ts +18 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +10 -4
- package/src/server/lib/queue/delivery.server.ts +114 -0
- package/src/server/lib/queue/schema-migration.server.ts +50 -0
- package/src/server/lib/queue/store.server.ts +185 -0
- package/src/server/lib/terminal/foreground.ts +73 -0
- package/src/server/lib/terminal/history.test.ts +118 -0
- package/src/server/lib/terminal/history.ts +205 -0
- package/src/server/lib/terminal/runtime.server.ts +338 -0
- package/src/server/lib/terminal/session.server.ts +215 -0
- package/src/server/lib/terminal/shell.test.ts +97 -0
- package/src/server/lib/terminal/shell.ts +103 -0
- package/src/server/routes/fs/git.ts +22 -12
- package/src/server/routes/index.ts +2 -1
- package/src/server/routes/sessions/index.ts +11 -3
- package/src/server/routes/sessions/queue.ts +121 -0
- package/src/server/routes/sessions/session.ts +1 -132
- package/src/server/routes/settings/commands.ts +1 -1
- package/src/server/routes/terminal/index.ts +2 -2
- package/src/server/routes/terminal/run.ts +8 -100
- package/src/server/routes/terminal/sessions.ts +24 -0
- package/src/server/routes/terminal/ws.ts +149 -0
- package/src/shared/lib/chat/ask-questions.test.ts +169 -0
- package/src/shared/lib/chat/ask-questions.ts +264 -0
- package/src/shared/lib/chat/omp/agent-events.test.ts +37 -1
- package/src/shared/lib/chat/omp/agent-events.ts +30 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +9 -1
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +45 -8
- package/src/shared/lib/code/lazy-window.test.ts +161 -0
- package/src/shared/lib/code/lazy-window.ts +156 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +50 -1
- package/src/shared/lib/code/syntax-highlight.ts +14 -1
- package/src/shared/lib/code/windowed-highlight.ts +63 -0
- package/src/shared/lib/markdown/mermaid.ts +8 -6
- package/src/shared/lib/omp/session/stream-state.server.ts +2 -3
- package/src/shared/lib/workspace/session-state/store.ts +1 -4
- package/src/shared/lib/workspace/terminal/protocol.ts +222 -0
- package/src/shared/types/omp/agent.ts +14 -2
- package/src/shared/types/terminal.ts +17 -15
- package/src/shared/types/workspace.ts +6 -2
- package/dist/client/static/css/index.51f68210a5.css +0 -2
- package/dist/client/static/js/async/5b.d88304bc13.js +0 -1
- package/dist/client/static/js/async/c.cd45b0ac45.js +0 -1
- package/dist/client/static/js/async/fw.6a51998a8e.js +0 -6
- package/dist/client/static/js/async/g.86e2256a70.js +0 -2
- package/dist/client/static/js/async/kc.bbdb35b771.js +0 -1
- package/dist/client/static/js/async/mw.ca1512563c.js +0 -10
- package/dist/client/static/js/async/rf.d626afc367.js +0 -2
- package/dist/client/static/js/async/t.cda4891526.js +0 -1
- package/dist/client/static/js/async/uf.24ca3d49c3.js +0 -2
- package/dist/client/static/js/async/uf.24ca3d49c3.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/v0.394ed37c6b.js +0 -2
- package/dist/client/static/js/async/v0.394ed37c6b.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/vy.bbe114c4e0.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/xn.afecebbbd9.js +0 -2
- package/dist/client/static/js/index.eccad0b70a.js +0 -33
- package/dist/client/static/js/index.eccad0b70a.js.LICENSE.txt +0 -4
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Ask.tsx +0 -205
- package/src/client/components/workspace/terminal-panel/InputBar.tsx +0 -82
- package/src/client/components/workspace/terminal-panel/LogEntry.tsx +0 -70
- package/src/client/hooks/workspace/terminal-output.ts +0 -66
- package/src/client/hooks/workspace/terminal.ts +0 -283
- package/src/server/lib/fs/terminal-cwd.ts +0 -49
- package/src/server/routes/terminal/stream.ts +0 -100
- /package/dist/client/static/js/async/{3y.4c2596f64c.js.LICENSE.txt → 3y.71f2ccf3b6.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.86e2256a70.js.LICENSE.txt → 5b.0d6ae50fe8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{dc.6dc4b0a04b.js.LICENSE.txt → dc.856fda6560.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{fw.6a51998a8e.js.LICENSE.txt → m.c038c71b9f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.4b29d34f93.js.LICENSE.txt → ma.2c261b04dd.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{xn.afecebbbd9.js.LICENSE.txt → ms.98960af4eb.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{mw.ca1512563c.js.LICENSE.txt → q.23af2a358f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.542c184fec.js.LICENSE.txt → s.222ee974f6.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{rf.d626afc367.js.LICENSE.txt → uf.46ad6209fa.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{s.413c2a4683.js.LICENSE.txt → xn.6360379f71.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{async/xw.ae67ba06fd.js.LICENSE.txt → index.0de15c5e17.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{uy.2e8929ed0c.js.LICENSE.txt → jp.50817896f3.js.LICENSE.txt} +0 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* SQLite-backed follow-up queue store (`queued_messages` table).
|
|
8
|
+
*
|
|
9
|
+
* The server is the single source of truth: the client panel is a view that
|
|
10
|
+
* mutates through per-item operations, and every mutation returns the
|
|
11
|
+
* canonical queue so callers reconcile from the response instead of merging
|
|
12
|
+
* local state. Rows carry a model snapshot (provider, model_id,
|
|
13
|
+
* thinking_level, access_mode) so auto-delivery replays the settings the item
|
|
14
|
+
* was queued with.
|
|
15
|
+
*
|
|
16
|
+
* Delivery claims the head with a transactional DELETE (bun:sqlite is
|
|
17
|
+
* synchronous, so BEGIN…COMMIT cannot interleave with another request), which
|
|
18
|
+
* makes "exactly one consumer sends the head" hold across tabs, reloads and
|
|
19
|
+
* concurrent nudges. A failed send re-inserts the claimed row at the head.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { getDb, getDbSync } from '@/server/db.server';
|
|
23
|
+
import { withTransaction } from '@/shared/lib/db/transaction.server';
|
|
24
|
+
import { isApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
25
|
+
import type { QueuedMessage, QueuedMessageModel } from '@/shared/types/chat';
|
|
26
|
+
|
|
27
|
+
interface QueueRow {
|
|
28
|
+
id: string;
|
|
29
|
+
session_id: string;
|
|
30
|
+
position: number;
|
|
31
|
+
message: string;
|
|
32
|
+
attachments: string;
|
|
33
|
+
provider: string | null;
|
|
34
|
+
model_id: string | null;
|
|
35
|
+
thinking_level: string | null;
|
|
36
|
+
access_mode: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function rowToQueuedMessage(row: QueueRow): QueuedMessage {
|
|
40
|
+
let attachments: unknown = [];
|
|
41
|
+
try {
|
|
42
|
+
attachments = JSON.parse(row.attachments);
|
|
43
|
+
} catch {
|
|
44
|
+
attachments = [];
|
|
45
|
+
}
|
|
46
|
+
const model: QueuedMessageModel | null =
|
|
47
|
+
row.provider && row.model_id
|
|
48
|
+
? {
|
|
49
|
+
provider: row.provider,
|
|
50
|
+
modelId: row.model_id,
|
|
51
|
+
thinkingLevel: row.thinking_level ?? 'auto',
|
|
52
|
+
accessMode: isApprovalMode(row.access_mode) ? row.access_mode : 'always-ask',
|
|
53
|
+
}
|
|
54
|
+
: null;
|
|
55
|
+
return {
|
|
56
|
+
id: row.id,
|
|
57
|
+
text: row.message,
|
|
58
|
+
attachments: Array.isArray(attachments) ? (attachments as QueuedMessage['attachments']) : [],
|
|
59
|
+
model,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function normalizeModel(raw: unknown): QueuedMessageModel | null {
|
|
64
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
65
|
+
const m = raw as Record<string, unknown>;
|
|
66
|
+
if (typeof m.provider !== 'string' || typeof m.modelId !== 'string') return null;
|
|
67
|
+
return {
|
|
68
|
+
provider: m.provider,
|
|
69
|
+
modelId: m.modelId,
|
|
70
|
+
thinkingLevel: typeof m.thinkingLevel === 'string' ? m.thinkingLevel : 'auto',
|
|
71
|
+
accessMode: isApprovalMode(m.accessMode) ? m.accessMode : 'always-ask',
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function serializeAttachments(attachments: unknown): string {
|
|
76
|
+
return JSON.stringify(Array.isArray(attachments) ? attachments : []);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface QueueItemInput {
|
|
80
|
+
text: string;
|
|
81
|
+
attachments?: unknown;
|
|
82
|
+
model?: unknown;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The session's queued follow-ups in delivery order. */
|
|
86
|
+
export async function listQueue(sessionId: string): Promise<QueuedMessage[]> {
|
|
87
|
+
const db = await getDb();
|
|
88
|
+
const rows = (await db.all(
|
|
89
|
+
'SELECT * FROM queued_messages WHERE session_id = ? ORDER BY position ASC',
|
|
90
|
+
[sessionId],
|
|
91
|
+
)) as QueueRow[];
|
|
92
|
+
return rows.map(rowToQueuedMessage);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Append one item; returns the canonical queue. */
|
|
96
|
+
export async function appendQueueItem(sessionId: string, input: QueueItemInput): Promise<QueuedMessage[]> {
|
|
97
|
+
const db = await getDb();
|
|
98
|
+
const model = normalizeModel(input.model);
|
|
99
|
+
const id = crypto.randomUUID();
|
|
100
|
+
db.raw.run(
|
|
101
|
+
`INSERT INTO queued_messages (id, session_id, position, message, attachments, provider, model_id, thinking_level, access_mode)
|
|
102
|
+
VALUES (?, ?, COALESCE((SELECT MAX(position) FROM queued_messages WHERE session_id = ?), -1) + 1, ?, ?, ?, ?, ?, ?)`,
|
|
103
|
+
[id, sessionId, sessionId, input.text, serializeAttachments(input.attachments),
|
|
104
|
+
model?.provider ?? null, model?.modelId ?? null, model?.thinkingLevel ?? null, model?.accessMode ?? null],
|
|
105
|
+
);
|
|
106
|
+
return listQueue(sessionId);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Patch one item's text/model; null when the id is unknown. */
|
|
110
|
+
export async function patchQueueItem(
|
|
111
|
+
sessionId: string,
|
|
112
|
+
id: string,
|
|
113
|
+
patch: { text?: unknown; model?: unknown },
|
|
114
|
+
): Promise<QueuedMessage[] | null> {
|
|
115
|
+
const db = await getDb();
|
|
116
|
+
const sets: string[] = [];
|
|
117
|
+
const values: unknown[] = [];
|
|
118
|
+
if (typeof patch.text === 'string') {
|
|
119
|
+
sets.push('message = ?');
|
|
120
|
+
values.push(patch.text);
|
|
121
|
+
}
|
|
122
|
+
if (patch.model !== undefined) {
|
|
123
|
+
const model = normalizeModel(patch.model);
|
|
124
|
+
sets.push('provider = ?', 'model_id = ?', 'thinking_level = ?', 'access_mode = ?');
|
|
125
|
+
values.push(model?.provider ?? null, model?.modelId ?? null, model?.thinkingLevel ?? null, model?.accessMode ?? null);
|
|
126
|
+
}
|
|
127
|
+
if (sets.length === 0) return listQueue(sessionId);
|
|
128
|
+
const result = await db.run(
|
|
129
|
+
`UPDATE queued_messages SET ${sets.join(', ')} WHERE session_id = ? AND id = ?`,
|
|
130
|
+
[...values, sessionId, id],
|
|
131
|
+
);
|
|
132
|
+
if (!result.changes) return null;
|
|
133
|
+
return listQueue(sessionId);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Remove one item by id; false when it was already claimed or absent. */
|
|
137
|
+
export async function deleteQueueItem(sessionId: string, id: string): Promise<boolean> {
|
|
138
|
+
const db = await getDb();
|
|
139
|
+
const result = await db.run('DELETE FROM queued_messages WHERE session_id = ? AND id = ?', [sessionId, id]);
|
|
140
|
+
return result.changes > 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Reorder the queue to exactly `orderedIds` (unknown ids are ignored). */
|
|
144
|
+
export async function reorderQueue(sessionId: string, orderedIds: string[]): Promise<QueuedMessage[]> {
|
|
145
|
+
const db = await getDb();
|
|
146
|
+
withTransaction(db, () => {
|
|
147
|
+
const update = db.raw.query('UPDATE queued_messages SET position = ? WHERE session_id = ? AND id = ?');
|
|
148
|
+
orderedIds.forEach((id, index) => update.run(index, sessionId, id));
|
|
149
|
+
});
|
|
150
|
+
return listQueue(sessionId);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Atomically claim the head for delivery: read + delete inside one
|
|
155
|
+
* transaction, so a concurrent claimant (second tab, reload nudge racing a
|
|
156
|
+
* run end) can never observe — and send — the same item twice. Null when the
|
|
157
|
+
* queue is empty. Synchronous by construction: `bun:sqlite` is sync, so the
|
|
158
|
+
* BEGIN…COMMIT block cannot interleave with another request.
|
|
159
|
+
*/
|
|
160
|
+
export function claimHeadQueueItem(sessionId: string): QueuedMessage | null {
|
|
161
|
+
const db = getDbSync();
|
|
162
|
+
return withTransaction(db, () => {
|
|
163
|
+
const row = db.raw
|
|
164
|
+
.query('SELECT * FROM queued_messages WHERE session_id = ? ORDER BY position ASC LIMIT 1')
|
|
165
|
+
.get(sessionId) as QueueRow | undefined;
|
|
166
|
+
if (!row) return null;
|
|
167
|
+
db.raw.run('DELETE FROM queued_messages WHERE id = ?', [row.id]);
|
|
168
|
+
return rowToQueuedMessage(row);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Put a failed delivery back at the head (position below the current minimum)
|
|
174
|
+
* so it is retried before anything else. The next run end or mount nudge
|
|
175
|
+
* picks it up again.
|
|
176
|
+
*/
|
|
177
|
+
export async function requeueHeadQueueItem(sessionId: string, item: QueuedMessage): Promise<void> {
|
|
178
|
+
const db = await getDb();
|
|
179
|
+
db.raw.run(
|
|
180
|
+
`INSERT INTO queued_messages (id, session_id, position, message, attachments, provider, model_id, thinking_level, access_mode)
|
|
181
|
+
VALUES (?, ?, COALESCE((SELECT MIN(position) FROM queued_messages WHERE session_id = ?), 0) - 1, ?, ?, ?, ?, ?, ?)`,
|
|
182
|
+
[item.id, sessionId, sessionId, item.text, serializeAttachments(item.attachments),
|
|
183
|
+
item.model?.provider ?? null, item.model?.modelId ?? null, item.model?.thinkingLevel ?? null, item.model?.accessMode ?? null],
|
|
184
|
+
);
|
|
185
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Is a shell busy, or sitting at its prompt?
|
|
8
|
+
*
|
|
9
|
+
* The PTY's foreground process group answers this exactly, and the kernel keeps
|
|
10
|
+
* it up to date: when a command runs in the foreground the terminal's `tpgid`
|
|
11
|
+
* becomes that command's group, and when the shell regains control `tpgid`
|
|
12
|
+
* returns to the shell's own pid. Verified on macOS against a real PTY —
|
|
13
|
+
* `sleep 30` moved `tpgid` off the shell's pid, `Ctrl+C` and a backgrounded job
|
|
14
|
+
* both left it on the shell's pid (a background job is by definition not the
|
|
15
|
+
* foreground job).
|
|
16
|
+
*
|
|
17
|
+
* Why it matters: the terminal cap counts *live* shells. "Live" has to mean
|
|
18
|
+
* "has work in flight", otherwise a panel that was opened, restarted, or
|
|
19
|
+
* switched a few times pins the budget with shells that are all idle at a
|
|
20
|
+
* prompt, and the next attach is rejected with nothing actually running.
|
|
21
|
+
*
|
|
22
|
+
* Cost is one `ps` call for the whole registry, so this is safe to run on every
|
|
23
|
+
* attach. It is advisory: any failure resolves to "not busy", because refusing
|
|
24
|
+
* a shell to save a slot is worse than letting one extra through.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const PS_TIMEOUT_MS = 1500;
|
|
28
|
+
|
|
29
|
+
/** `pid` is the shell's process (it is the PTY session leader, so pgid === pid). */
|
|
30
|
+
export interface ShellProcess {
|
|
31
|
+
id: string;
|
|
32
|
+
pid: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function detectBusyShells(shells: ShellProcess[]): Promise<Set<string>> {
|
|
36
|
+
const busy = new Set<string>();
|
|
37
|
+
if (shells.length === 0 || process.platform === 'win32') return busy;
|
|
38
|
+
|
|
39
|
+
const byPid = new Map<number, string>();
|
|
40
|
+
for (const shell of shells) byPid.set(shell.pid, shell.id);
|
|
41
|
+
|
|
42
|
+
const ps = Bun.spawn(['ps', '-o', 'pid=,tpgid=', '-p', shells.map((s) => s.pid).join(',')], {
|
|
43
|
+
stdout: 'pipe',
|
|
44
|
+
stderr: 'ignore',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
let output = '';
|
|
48
|
+
try {
|
|
49
|
+
output = await Promise.race([
|
|
50
|
+
new Response(ps.stdout).text(),
|
|
51
|
+
Bun.sleep(PS_TIMEOUT_MS).then(() => ''),
|
|
52
|
+
]);
|
|
53
|
+
} catch {
|
|
54
|
+
// The pipe died; fall through to the kill and the empty result.
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
ps.kill('SIGKILL');
|
|
58
|
+
} catch {
|
|
59
|
+
// Already exited.
|
|
60
|
+
}
|
|
61
|
+
void ps.exited.catch(() => {});
|
|
62
|
+
|
|
63
|
+
for (const line of output.split('\n')) {
|
|
64
|
+
const [pidText, tpgidText] = line.trim().split(/\s+/);
|
|
65
|
+
const pid = Number(pidText);
|
|
66
|
+
const tpgid = Number(tpgidText);
|
|
67
|
+
if (!Number.isFinite(pid) || !Number.isFinite(tpgid)) continue;
|
|
68
|
+
const id = byPid.get(pid);
|
|
69
|
+
// The shell holds the terminal exactly when it is its own foreground group.
|
|
70
|
+
if (id !== undefined && tpgid !== pid) busy.add(id);
|
|
71
|
+
}
|
|
72
|
+
return busy;
|
|
73
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import { createTerminalHistory, sanitizeReplayChunk, trimUtf8ToBytes } from '@/server/lib/terminal/history';
|
|
9
|
+
|
|
10
|
+
const decode = (bytes: Uint8Array) => Buffer.from(bytes).toString('utf8');
|
|
11
|
+
const encode = (text: string) => new Uint8Array(Buffer.from(text, 'utf8'));
|
|
12
|
+
|
|
13
|
+
describe('trimUtf8ToBytes', () => {
|
|
14
|
+
test('keeps a short buffer whole', () => {
|
|
15
|
+
const bytes = encode('hello');
|
|
16
|
+
expect(trimUtf8ToBytes(bytes, 16)).toBe(bytes);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('never starts inside a multi-byte character', () => {
|
|
20
|
+
// 'é' is 2 bytes, '😀' is 4 — a naive slice at the boundary would leave
|
|
21
|
+
// continuation bytes at the head and decode the whole tail as U+FFFD.
|
|
22
|
+
const bytes = encode('ab😀cd');
|
|
23
|
+
// 8 bytes cut to 5 lands inside the emoji: step forward past it.
|
|
24
|
+
expect(decode(trimUtf8ToBytes(bytes, 5))).toBe('cd');
|
|
25
|
+
// Cut to 6 lands exactly on its first byte: the emoji is kept whole.
|
|
26
|
+
expect(decode(trimUtf8ToBytes(bytes, 6))).toBe('😀cd');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('drops the cut character instead of emitting replacement bytes', () => {
|
|
30
|
+
const bytes = encode('😀');
|
|
31
|
+
const trimmed = trimUtf8ToBytes(bytes, 1);
|
|
32
|
+
expect(decode(trimmed)).toBe('');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('sanitizeReplayChunk', () => {
|
|
37
|
+
test('keeps ordinary output byte-for-byte', () => {
|
|
38
|
+
const input = encode('\x1b[31mred\x1b[0m plain\r\n');
|
|
39
|
+
const { kept, rest } = sanitizeReplayChunk(input);
|
|
40
|
+
expect(kept).toBe(input);
|
|
41
|
+
expect(rest.length).toBe(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('drops device-attribute and cursor-position queries', () => {
|
|
45
|
+
const { kept, rest } = sanitizeReplayChunk(encode('a\x1b[c b\x1b[6n c'));
|
|
46
|
+
expect(decode(kept)).toBe('a b c');
|
|
47
|
+
expect(rest.length).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('drops color queries in both BEL and ST terminated forms', () => {
|
|
51
|
+
const { kept } = sanitizeReplayChunk(encode('x\x1b]11;?\x07y\x1b]10;?\x1b\\z'));
|
|
52
|
+
expect(decode(kept)).toBe('xyz');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('keeps OSC payloads that are not queries', () => {
|
|
56
|
+
// The window-title sequence a shell emits on every prompt is not a query.
|
|
57
|
+
const input = encode('\x1b]7;file://host/tmp\x07');
|
|
58
|
+
expect(sanitizeReplayChunk(input).kept).toBe(input);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('drops a query whose bytes straddle two chunks', () => {
|
|
62
|
+
// The split is what makes this stateful: neither chunk alone is a complete
|
|
63
|
+
// query, but their concatenation is one.
|
|
64
|
+
const history = createTerminalHistory();
|
|
65
|
+
history.append(encode('abc\x1b['));
|
|
66
|
+
history.append(encode('c done'));
|
|
67
|
+
expect(decode(history.replay())).toBe('abc done');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('does not hold back a sequence it can see is not a query', () => {
|
|
71
|
+
const { kept, rest } = sanitizeReplayChunk(encode('a\x1b[32m'));
|
|
72
|
+
expect(decode(kept)).toBe('a\x1b[32m');
|
|
73
|
+
expect(rest.length).toBe(0);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('createTerminalHistory', () => {
|
|
78
|
+
test('accumulates appended chunks in order', () => {
|
|
79
|
+
const history = createTerminalHistory();
|
|
80
|
+
history.append(encode('one '));
|
|
81
|
+
history.append(encode('two'));
|
|
82
|
+
expect(decode(history.replay())).toBe('one two');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('trims from the front once the cap is exceeded', () => {
|
|
86
|
+
const history = createTerminalHistory(16);
|
|
87
|
+
history.append(encode('aaaaaaaaaa'));
|
|
88
|
+
history.append(encode('bbbbbbbbbb'));
|
|
89
|
+
// Whole chunks are dropped, so the retained size lands just under the cap
|
|
90
|
+
// instead of being re-sliced on every append.
|
|
91
|
+
expect(decode(history.replay())).toBe('bbbbbbbbbb');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('trims a single oversized chunk at a UTF-8 boundary', () => {
|
|
95
|
+
const history = createTerminalHistory(5);
|
|
96
|
+
history.append(encode('ab😀cd'));
|
|
97
|
+
// A byte-wise cut at offset 3 would land inside the emoji and decode the
|
|
98
|
+
// rest as U+FFFD; the trim steps forward to the next sequence start.
|
|
99
|
+
expect(decode(history.replay())).toBe('cd');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('keeps the newest chunks when several are dropped', () => {
|
|
103
|
+
const history = createTerminalHistory(7);
|
|
104
|
+
history.append(encode('ab'));
|
|
105
|
+
history.append(encode('cd'));
|
|
106
|
+
history.append(encode('ef'));
|
|
107
|
+
history.append(encode('gh'));
|
|
108
|
+
// 8 bytes against a 7-byte cap drops the oldest chunk whole.
|
|
109
|
+
expect(decode(history.replay())).toBe('cdefgh');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('clear empties the replay buffer', () => {
|
|
113
|
+
const history = createTerminalHistory();
|
|
114
|
+
history.append(encode('gone'));
|
|
115
|
+
history.clear();
|
|
116
|
+
expect(history.replay().length).toBe(0);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Server-side scrollback for a PTY session, plus the replay sanitizer.
|
|
8
|
+
*
|
|
9
|
+
* Two failure modes this module exists to prevent, both silent when wrong:
|
|
10
|
+
*
|
|
11
|
+
* 1. Trimming must be UTF-8-safe. A byte slice at the cap boundary can land
|
|
12
|
+
* inside a multi-byte sequence, and every byte after it decodes as U+FFFD —
|
|
13
|
+
* the whole tail of the scrollback turns into replacement characters.
|
|
14
|
+
* 2. A replayed buffer must not contain terminal *queries*. A live client
|
|
15
|
+
* answers `CSI c` (device attributes), `CSI 6n` (cursor position) and
|
|
16
|
+
* `OSC 10/11;?` (colors) by writing the reply into the PTY, so replaying a
|
|
17
|
+
* query makes those bytes arrive at the shell prompt as stray input. Live
|
|
18
|
+
* output is byte-for-byte untouched; only the replay copy is filtered.
|
|
19
|
+
*
|
|
20
|
+
* Filtering is streaming: a control sequence split across two PTY reads is
|
|
21
|
+
* carried into the next chunk instead of being emitted half-parsed.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const MAX_HISTORY_BYTES = 512 * 1024;
|
|
25
|
+
|
|
26
|
+
/** Longest partial sequence held back before it is flushed as ordinary data. */
|
|
27
|
+
const MAX_PENDING_BYTES = 128;
|
|
28
|
+
|
|
29
|
+
const ESC = 0x1b;
|
|
30
|
+
const BEL = 0x07;
|
|
31
|
+
const CSI = 0x5b; // [
|
|
32
|
+
const OSC = 0x5d; // ]
|
|
33
|
+
/** DCS / SOS / PM / APC — string sequences terminated by ST only. */
|
|
34
|
+
const STRING_INTROS: Record<number, true> = { 0x50: true, 0x58: true, 0x5e: true, 0x5f: true };
|
|
35
|
+
|
|
36
|
+
const EMPTY = new Uint8Array(0);
|
|
37
|
+
|
|
38
|
+
export interface TerminalHistory {
|
|
39
|
+
append(chunk: Uint8Array): void;
|
|
40
|
+
/** The replay buffer. Views into it stay valid until the next `append`. */
|
|
41
|
+
replay(): Uint8Array;
|
|
42
|
+
clear(): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createTerminalHistory(maxBytes = MAX_HISTORY_BYTES): TerminalHistory {
|
|
46
|
+
let chunks: Uint8Array[] = [];
|
|
47
|
+
let total = 0;
|
|
48
|
+
let pending: Uint8Array = EMPTY;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
append(chunk) {
|
|
52
|
+
if (chunk.length === 0) return;
|
|
53
|
+
const input = pending.length === 0 ? chunk : joinParts([pending, chunk]);
|
|
54
|
+
const { kept, rest } = sanitizeReplayChunk(input);
|
|
55
|
+
pending = rest;
|
|
56
|
+
if (kept.length > 0) {
|
|
57
|
+
chunks.push(kept);
|
|
58
|
+
total += kept.length;
|
|
59
|
+
}
|
|
60
|
+
// Drop whole chunks first: re-slicing the buffer on every append would
|
|
61
|
+
// copy up to 512 KiB per PTY read, which a chatty program turns into a
|
|
62
|
+
// memcpy storm. Only the boundary chunk is trimmed byte-wise.
|
|
63
|
+
while (total > maxBytes && chunks.length > 1) {
|
|
64
|
+
total -= chunks[0].length;
|
|
65
|
+
chunks.shift();
|
|
66
|
+
}
|
|
67
|
+
if (total > maxBytes && chunks.length === 1) {
|
|
68
|
+
chunks[0] = trimUtf8ToBytes(chunks[0], maxBytes);
|
|
69
|
+
total = chunks[0].length;
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
replay() {
|
|
73
|
+
if (chunks.length === 0) return EMPTY;
|
|
74
|
+
return chunks.length === 1 ? chunks[0] : joinParts(chunks);
|
|
75
|
+
},
|
|
76
|
+
clear() {
|
|
77
|
+
chunks = [];
|
|
78
|
+
total = 0;
|
|
79
|
+
pending = EMPTY;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Keep the last `maxBytes`, never starting inside a multi-byte sequence. */
|
|
85
|
+
export function trimUtf8ToBytes(bytes: Uint8Array, maxBytes: number): Uint8Array {
|
|
86
|
+
if (bytes.length <= maxBytes) return bytes;
|
|
87
|
+
let start = bytes.length - maxBytes;
|
|
88
|
+
// 10xxxxxx is a continuation byte: step forward to the next sequence start.
|
|
89
|
+
while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start += 1;
|
|
90
|
+
return bytes.subarray(start);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Split `input` into the bytes that may be replayed and the trailing fragment
|
|
95
|
+
* of an unfinished control sequence. Dropped sequences are queries only.
|
|
96
|
+
*/
|
|
97
|
+
export function sanitizeReplayChunk(input: Uint8Array): { kept: Uint8Array; rest: Uint8Array } {
|
|
98
|
+
const parts: Uint8Array[] = [];
|
|
99
|
+
let from = 0;
|
|
100
|
+
let i = 0;
|
|
101
|
+
let dropped = false;
|
|
102
|
+
|
|
103
|
+
while (i < input.length) {
|
|
104
|
+
if (input[i] !== ESC) {
|
|
105
|
+
i += 1;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const scan = scanEscape(input, i);
|
|
109
|
+
if (scan === null) {
|
|
110
|
+
if (i > from) parts.push(input.subarray(from, i));
|
|
111
|
+
const rest = input.subarray(i);
|
|
112
|
+
// A program that opened a string sequence and never closed it must not
|
|
113
|
+
// swallow the rest of the scrollback: emit the fragment and move on.
|
|
114
|
+
if (rest.length > MAX_PENDING_BYTES) {
|
|
115
|
+
parts.push(rest);
|
|
116
|
+
return { kept: joinParts(parts), rest: EMPTY };
|
|
117
|
+
}
|
|
118
|
+
return { kept: joinParts(parts), rest };
|
|
119
|
+
}
|
|
120
|
+
if (scan.drop) {
|
|
121
|
+
if (i > from) parts.push(input.subarray(from, i));
|
|
122
|
+
from = scan.end;
|
|
123
|
+
dropped = true;
|
|
124
|
+
}
|
|
125
|
+
i = scan.end;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!dropped) return { kept: input, rest: EMPTY };
|
|
129
|
+
if (from < input.length) parts.push(input.subarray(from));
|
|
130
|
+
return { kept: joinParts(parts), rest: EMPTY };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface EscapeScan {
|
|
134
|
+
/** Index just past the sequence. */
|
|
135
|
+
end: number;
|
|
136
|
+
/** True when the sequence is a query the replay must not contain. */
|
|
137
|
+
drop: boolean;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Null means the sequence is not complete within `input`. */
|
|
141
|
+
function scanEscape(input: Uint8Array, start: number): EscapeScan | null {
|
|
142
|
+
const kind = input[start + 1];
|
|
143
|
+
if (kind === undefined) return null;
|
|
144
|
+
if (kind === CSI) return scanCsi(input, start);
|
|
145
|
+
if (kind === OSC) return scanOsc(input, start);
|
|
146
|
+
if (STRING_INTROS[kind] === true) return scanString(input, start);
|
|
147
|
+
// Every other escape is two bytes (ESC c, ESC 7, ESC M, …). Emitting the
|
|
148
|
+
// pair leaves the remaining bytes identical to the input either way.
|
|
149
|
+
return { end: start + 2, drop: false };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function scanCsi(input: Uint8Array, start: number): EscapeScan | null {
|
|
153
|
+
for (let i = start + 2; i < input.length; i += 1) {
|
|
154
|
+
const byte = input[i];
|
|
155
|
+
// 0x40–0x7e is the final byte; params/intermediates are below it.
|
|
156
|
+
if (byte >= 0x40 && byte <= 0x7e) {
|
|
157
|
+
return { end: i + 1, drop: isCsiQuery(input, start + 2, i) };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isCsiQuery(input: Uint8Array, from: number, final: number): boolean {
|
|
164
|
+
const finalByte = input[final];
|
|
165
|
+
if (finalByte === 0x63) return true; // 'c' — DA1/DA2 device-attributes request
|
|
166
|
+
const params = asciiOf(input, from, final);
|
|
167
|
+
if (finalByte === 0x6e) return /^\??[56]$/.test(params); // 'n' — DSR / cursor position
|
|
168
|
+
if (finalByte === 0x70) return params.includes('$') && params.includes('2031'); // DECRQM mode query
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function scanOsc(input: Uint8Array, start: number): EscapeScan | null {
|
|
173
|
+
for (let i = start + 2; i < input.length; i += 1) {
|
|
174
|
+
const byte = input[i];
|
|
175
|
+
if (byte === BEL) return { end: i + 1, drop: isOscQuery(input, start + 2, i) };
|
|
176
|
+
if (byte === ESC && input[i + 1] === 0x5c) return { end: i + 2, drop: isOscQuery(input, start + 2, i) };
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Color queries: `OSC 10;?`, `OSC 11;?`, `OSC 12;?` and palette `OSC 4;n;?`. */
|
|
182
|
+
function isOscQuery(input: Uint8Array, from: number, to: number): boolean {
|
|
183
|
+
const body = asciiOf(input, from, to);
|
|
184
|
+
const fields = body.split(';');
|
|
185
|
+
if (!/^(4|10|11|12)$/.test(fields[0])) return false;
|
|
186
|
+
return fields.length > 1 && fields[fields.length - 1] === '?';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function scanString(input: Uint8Array, start: number): EscapeScan | null {
|
|
190
|
+
for (let i = start + 2; i < input.length; i += 1) {
|
|
191
|
+
if (input[i] === ESC && input[i + 1] === 0x5c) return { end: i + 2, drop: false };
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function asciiOf(input: Uint8Array, from: number, to: number): string {
|
|
197
|
+
let out = '';
|
|
198
|
+
for (let i = from; i < to; i += 1) out += String.fromCharCode(input[i]);
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function joinParts(parts: Uint8Array[]): Uint8Array {
|
|
203
|
+
if (parts.length === 1) return parts[0];
|
|
204
|
+
return Buffer.concat(parts);
|
|
205
|
+
}
|