ompchamber 3.4.0 → 3.6.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/index.js +387 -383
- package/dist/client/static/js/index-7n838fz5.css +1 -0
- package/dist/client/static/js/{index-h05z90mw.js → index-qp2sfmn2.js} +614 -615
- package/package.json +1 -1
- package/src/cli/lib/commands/status.js +7 -0
- package/src/client/components/common/session-actions-menu/index.tsx +201 -0
- package/src/client/components/common/session-delete-modal/index.tsx +128 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +18 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +109 -99
- package/src/client/components/layout/session-sidebar/about-modal/UpdateLog.tsx +50 -0
- package/src/client/components/layout/session-sidebar/about-modal/UpdateSection.tsx +12 -17
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +18 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +82 -64
- package/src/client/components/settings/ProjectSelectorDropdown.tsx +8 -2
- package/src/client/components/settings/SettingsSidebarList.tsx +5 -1
- package/src/client/components/settings/categories/CommandSettings.tsx +47 -9
- package/src/client/components/settings/categories/SkillCatalogSettings.tsx +23 -43
- package/src/client/components/settings/categories/SkillSettings.tsx +51 -7
- package/src/client/components/settings/categories/command-settings/DetailPane.tsx +109 -103
- package/src/client/components/settings/categories/command-settings/SidebarList.tsx +6 -1
- package/src/client/components/settings/categories/skill-settings/DetailPane.tsx +130 -31
- package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +9 -3
- package/src/client/data/settings/command.ts +0 -40
- package/src/client/hooks/settings/workspace-roots.ts +99 -0
- package/src/client/hooks/ui/updates/index.ts +178 -0
- package/src/client/hooks/workspace/session-delete.ts +94 -0
- package/src/server/db.server.ts +41 -11
- package/src/server/index.ts +17 -1
- package/src/server/lib/btw/purge.server.ts +56 -0
- package/src/server/lib/db/client.ts +17 -0
- package/src/server/lib/db/compact-chat-overlay.test.ts +121 -0
- package/src/server/lib/db/compact-chat-overlay.ts +90 -0
- package/src/server/lib/fs/path-segment.test.ts +100 -0
- package/src/server/lib/fs/path-segment.ts +45 -0
- package/src/server/lib/lifecycle/fd-pressure.test.ts +66 -0
- package/src/server/lib/lifecycle/fd-pressure.ts +159 -0
- package/src/server/lib/omp/config/commands.test.ts +167 -0
- package/src/server/lib/omp/config/commands.ts +195 -0
- package/src/server/lib/omp/config/scope.test.ts +49 -0
- package/src/server/lib/omp/config/scope.ts +65 -0
- package/src/server/lib/omp/config/skills-catalog.ts +35 -3
- package/src/server/lib/omp/config/skills.test.ts +105 -0
- package/src/server/lib/omp/config/skills.ts +260 -47
- package/src/server/lib/omp/rpc/process.ts +22 -14
- package/src/server/lib/omp/rpc/session-registry.ts +6 -0
- package/src/server/lib/omp/rpc/utility.ts +45 -23
- package/src/server/lib/omp/session/delete.server.ts +136 -0
- package/src/server/lib/omp/session/merge-stored-anchor.test.ts +85 -0
- package/src/server/lib/omp/session/merge-stored-overlay-rows.test.ts +106 -0
- package/src/server/lib/omp/session/merge-stored.ts +78 -11
- package/src/server/lib/omp/session/reader.ts +13 -0
- package/src/server/lib/omp/session/reload.server.test.ts +84 -0
- package/src/server/lib/omp/session/reload.server.ts +47 -0
- package/src/server/lib/omp/session/subagent-presence.ts +40 -9
- package/src/server/lib/terminal/liveness.server.ts +65 -0
- package/src/server/lib/terminal/runtime.server.ts +70 -117
- package/src/server/lib/terminal/session.server.ts +5 -2
- package/src/server/lib/terminal/store.ts +72 -0
- package/src/server/lib/updates/apply.ts +62 -33
- package/src/server/lib/updates/install.ts +9 -2
- package/src/server/lib/updates/omp.ts +37 -9
- package/src/server/lib/updates/single-flight.test.ts +61 -0
- package/src/server/lib/updates/single-flight.ts +68 -0
- package/src/server/routes/chat/session.ts +13 -3
- package/src/server/routes/health/probe.ts +8 -0
- package/src/server/routes/omp/index.ts +0 -2
- package/src/server/routes/sessions/delete.test.ts +84 -0
- package/src/server/routes/sessions/delete.ts +83 -0
- package/src/server/routes/sessions/index.ts +5 -0
- package/src/server/routes/settings/commands.ts +102 -15
- package/src/server/routes/settings/skills.ts +165 -113
- package/src/server/routes/terminal/sessions.ts +1 -1
- package/src/server/routes/updates/apply.ts +101 -14
- package/src/shared/lib/chat/composer/client.ts +27 -18
- package/src/shared/lib/chat/composer/scope.test.ts +87 -0
- package/src/shared/lib/markdown/katex.ts +11 -1
- package/src/shared/lib/markdown/marked.ts +68 -13
- package/src/shared/lib/markdown/math.test.ts +53 -1
- package/src/shared/lib/updates/progress.test.ts +54 -0
- package/src/shared/lib/updates/progress.ts +50 -0
- package/src/shared/lib/workspace/session-state/store.ts +20 -0
- package/src/shared/types/settings/command.ts +15 -2
- package/src/shared/types/settings/skill.ts +13 -0
- package/dist/client/static/js/index-bsxxt89j.css +0 -1
- package/src/client/hooks/ui/updates.ts +0 -108
- package/src/server/routes/omp/commands.ts +0 -29
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single-flight contract for updates. Two properties matter and neither is
|
|
3
|
+
* visible from the call site: a run refusing to start while another holds the
|
|
4
|
+
* slot, and the slot always being free again afterwards — a stuck claim would
|
|
5
|
+
* answer "already running" until the server restarted.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
|
|
10
|
+
import { runningUpdate, UpdateInProgressError, withUpdateSlot } from '@/server/lib/updates/single-flight';
|
|
11
|
+
|
|
12
|
+
/** A run that stays open until `release()` is called. */
|
|
13
|
+
function deferred() {
|
|
14
|
+
let release!: (value: string) => void;
|
|
15
|
+
const promise = new Promise<string>((resolve) => {
|
|
16
|
+
release = resolve;
|
|
17
|
+
});
|
|
18
|
+
return { promise, release };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('withUpdateSlot', () => {
|
|
22
|
+
test('a run is refused while another holds the slot', async () => {
|
|
23
|
+
const gate = deferred();
|
|
24
|
+
const running = withUpdateSlot('omp', () => gate.promise);
|
|
25
|
+
expect(runningUpdate()).toBe('omp');
|
|
26
|
+
|
|
27
|
+
await expect(withUpdateSlot('ompchamber', async () => 'never')).rejects.toThrow(UpdateInProgressError);
|
|
28
|
+
|
|
29
|
+
gate.release('done');
|
|
30
|
+
await expect(running).resolves.toBe('done');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('the refusal names the update that is already running', async () => {
|
|
34
|
+
const gate = deferred();
|
|
35
|
+
const running = withUpdateSlot('ompchamber', () => gate.promise);
|
|
36
|
+
|
|
37
|
+
const error = await withUpdateSlot('omp', async () => 'never').catch((cause: unknown) => cause);
|
|
38
|
+
expect(error).toBeInstanceOf(UpdateInProgressError);
|
|
39
|
+
expect((error as Error).message).toContain('OMPChamber');
|
|
40
|
+
|
|
41
|
+
gate.release('done');
|
|
42
|
+
await expect(running).resolves.toBe('done');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('the slot is free again once the run settles', async () => {
|
|
46
|
+
await expect(withUpdateSlot('omp', async () => 'first')).resolves.toBe('first');
|
|
47
|
+
|
|
48
|
+
expect(runningUpdate()).toBeNull();
|
|
49
|
+
await expect(withUpdateSlot('omp', async () => 'ok')).resolves.toBe('ok');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a run that throws does not leave the slot held', async () => {
|
|
53
|
+
await expect(
|
|
54
|
+
withUpdateSlot('omp', async () => {
|
|
55
|
+
throw new Error('boom');
|
|
56
|
+
}),
|
|
57
|
+
).rejects.toThrow('boom');
|
|
58
|
+
|
|
59
|
+
expect(runningUpdate()).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One update at a time, for the whole server.
|
|
3
|
+
*
|
|
4
|
+
* An update is long and it replaces an install in place, so overlapping runs
|
|
5
|
+
* are not merely wasteful: two `updateOmpChamber` calls mean two
|
|
6
|
+
* `git merge --ff-only` plus two `bun install` in the same checkout, and two
|
|
7
|
+
* `omp update` calls race over the same binary.
|
|
8
|
+
*
|
|
9
|
+
* The console cannot be the gate. Its "a run is in flight" state is JavaScript,
|
|
10
|
+
* so a page reload forgets it and offers the button again while the first run
|
|
11
|
+
* is still going — measured: a reload mid-run followed by one more click left
|
|
12
|
+
* two `omp update` children alive at once.
|
|
13
|
+
*
|
|
14
|
+
* The claim is taken SYNCHRONOUSLY, before the run's first `await`, so two
|
|
15
|
+
* requests arriving in the same tick cannot both pass. Release is bound to the
|
|
16
|
+
* token `beginUpdate` issues rather than to the target name, so a stale release
|
|
17
|
+
* cannot free a claim belonging to a different run.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { UpdateTarget } from '@/shared/types/updates';
|
|
21
|
+
|
|
22
|
+
/** What the console is told when a second run is refused. */
|
|
23
|
+
export function updateBusyMessage(target: UpdateTarget): string {
|
|
24
|
+
return `An ${target === 'omp' ? 'Oh-My-Pi' : 'OMPChamber'} update is already running.`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class UpdateInProgressError extends Error {
|
|
28
|
+
constructor(target: UpdateTarget) {
|
|
29
|
+
super(updateBusyMessage(target));
|
|
30
|
+
this.name = 'UpdateInProgressError';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let held: { token: symbol; target: UpdateTarget } | null = null;
|
|
35
|
+
|
|
36
|
+
/** The target of the update in flight, or null when none is. */
|
|
37
|
+
export function runningUpdate(): UpdateTarget | null {
|
|
38
|
+
return held?.target ?? null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Take the slot. Throws when another run already holds it. */
|
|
42
|
+
function beginUpdate(target: UpdateTarget): symbol {
|
|
43
|
+
if (held) throw new UpdateInProgressError(held.target);
|
|
44
|
+
const token = Symbol('update');
|
|
45
|
+
held = { token, target };
|
|
46
|
+
return token;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Release the slot; a token that no longer owns it is ignored. */
|
|
50
|
+
function releaseUpdate(token: symbol): void {
|
|
51
|
+
if (held?.token === token) held = null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Run `run` while holding the slot.
|
|
56
|
+
*
|
|
57
|
+
* The release is in a `finally`, so a failed update cannot leave the server
|
|
58
|
+
* answering "already running" forever — there is no other way to clear the
|
|
59
|
+
* flag, and a stuck flag would need a restart.
|
|
60
|
+
*/
|
|
61
|
+
export async function withUpdateSlot<T>(target: UpdateTarget, run: () => Promise<T>): Promise<T> {
|
|
62
|
+
const token = beginUpdate(target);
|
|
63
|
+
try {
|
|
64
|
+
return await run();
|
|
65
|
+
} finally {
|
|
66
|
+
releaseUpdate(token);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -6,7 +6,7 @@ import { getSessionData } from '@/client/data/mock/chat';
|
|
|
6
6
|
import { isMockMode } from '@/server/mock.server';
|
|
7
7
|
import { findSessionFileById } from '@/server/lib/omp/session/locator';
|
|
8
8
|
import { loadSessionMessages, loadSessionModel, loadSessionThinkingLevel, loadSessionTitle } from '@/server/lib/omp/session/messages';
|
|
9
|
-
import { mergeOmpAttachments } from '@/server/lib/omp/session/merge-stored';
|
|
9
|
+
import { mergeOmpAttachments, overlayRowsForOmpSession } from '@/server/lib/omp/session/merge-stored';
|
|
10
10
|
import { readRawHeaderLine } from '@/server/lib/omp/session/files';
|
|
11
11
|
import { formatNewSessionTitle } from '@/shared/lib/omp/session/default-title';
|
|
12
12
|
|
|
@@ -202,9 +202,19 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
|
|
202
202
|
updatedMessages = [...currentMessages, data.message];
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
// A session omp owns on disk has its transcript in the JSONL, so the
|
|
206
|
+
// overlay keeps only the rows omp cannot record there — the user turns
|
|
207
|
+
// (raw composer text, attachment metadata) and the notice rows. A session
|
|
208
|
+
// with no file is chamber-created: the DB is its only copy, so it stays
|
|
209
|
+
// whole. The gate is the file's existence, never the id's shape — an omp
|
|
210
|
+
// uuid whose file is gone still needs its full row.
|
|
211
|
+
const persistedMessages = (await findSessionFileById(sessionId))
|
|
212
|
+
? overlayRowsForOmpSession(updatedMessages)
|
|
213
|
+
: updatedMessages;
|
|
214
|
+
|
|
205
215
|
await db.run(
|
|
206
216
|
'INSERT OR REPLACE INTO chat_sessions (session_id, title, messages, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)',
|
|
207
|
-
[sessionId, title, JSON.stringify(
|
|
217
|
+
[sessionId, title, JSON.stringify(persistedMessages)]
|
|
208
218
|
);
|
|
209
219
|
|
|
210
220
|
return json({
|
|
@@ -212,7 +222,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
|
|
212
222
|
session: {
|
|
213
223
|
id: sessionId,
|
|
214
224
|
title,
|
|
215
|
-
messages:
|
|
225
|
+
messages: persistedMessages,
|
|
216
226
|
},
|
|
217
227
|
});
|
|
218
228
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { json, NO_STORE_HEADERS } from '@/server/lib/remix-compat';
|
|
2
2
|
import pkg from '@/../package.json';
|
|
3
|
+
import { countOpenFileDescriptors } from '@/server/lib/lifecycle/fd-pressure';
|
|
3
4
|
import { resolveLaunchMode } from '@/server/lib/lifecycle/launch-mode';
|
|
4
5
|
import { isMockMode } from '@/server/mock.server';
|
|
5
6
|
|
|
@@ -9,6 +10,12 @@ const STARTED_ISO = new Date(STARTED_AT).toISOString();
|
|
|
9
10
|
/**
|
|
10
11
|
* GET /api/health — lightweight readiness probe for the `ompchamber status` CLI.
|
|
11
12
|
* Dependency-light by design: never touches the database and never throws.
|
|
13
|
+
*
|
|
14
|
+
* It does sweep the descriptor table (`countOpenFileDescriptors`, ~1 ms): a dev
|
|
15
|
+
* server walks toward the 10,240-descriptor point where every `Bun.spawn` in
|
|
16
|
+
* the process fails, and the sweep is the only way to say so *before* the
|
|
17
|
+
* terminal panel, the omp child and git all break at once. Null where the host
|
|
18
|
+
* cannot answer.
|
|
12
19
|
*/
|
|
13
20
|
export async function loader() {
|
|
14
21
|
return json(
|
|
@@ -30,6 +37,7 @@ export async function loader() {
|
|
|
30
37
|
launchMode: resolveLaunchMode(),
|
|
31
38
|
port: Number(Bun.env.PORT) || 3000,
|
|
32
39
|
host: Bun.env.HOST || 'localhost',
|
|
40
|
+
fds: countOpenFileDescriptors(),
|
|
33
41
|
},
|
|
34
42
|
{ headers: NO_STORE_HEADERS },
|
|
35
43
|
);
|
|
@@ -3,7 +3,6 @@ import * as ompState from '@/server/routes/omp/state';
|
|
|
3
3
|
import * as ompSidebar from '@/server/routes/omp/sidebar';
|
|
4
4
|
import * as ompSessionStats from '@/server/routes/omp/session-stats';
|
|
5
5
|
import * as ompSessionTodos from '@/server/routes/omp/session-todos';
|
|
6
|
-
import * as ompCommands from '@/server/routes/omp/commands';
|
|
7
6
|
import * as ompExtensions from '@/server/routes/omp/extensions';
|
|
8
7
|
import * as ompLogin from '@/server/routes/omp/login';
|
|
9
8
|
import * as ompPlugins from '@/server/routes/omp/plugins';
|
|
@@ -14,7 +13,6 @@ export const ompBindings: HandlerBinding[] = [
|
|
|
14
13
|
...bindingsFor(ompSidebar, '/api/omp/sidebar'),
|
|
15
14
|
...bindingsFor(ompSessionStats, '/api/omp/session-stats'),
|
|
16
15
|
...bindingsFor(ompSessionTodos, '/api/omp/session-todos'),
|
|
17
|
-
...bindingsFor(ompCommands, '/api/omp/commands'),
|
|
18
16
|
...bindingsFor(ompExtensions, '/api/omp/extensions'),
|
|
19
17
|
...bindingsFor(ompLogin, '/api/omp/login'),
|
|
20
18
|
...bindingsFor(ompPlugins, '/api/omp/plugins'),
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The delete route's refusal branches.
|
|
8
|
+
*
|
|
9
|
+
* Each one guards a different failure, and two of them are about blast radius
|
|
10
|
+
* rather than tidiness: the id names a directory under the sessions root and
|
|
11
|
+
* another under the btw root, so a value that is not one segment deletes far
|
|
12
|
+
* more than the session it names (`.` normalized to the btw root itself and
|
|
13
|
+
* wiped every session's side questions — see `fs/path-segment`).
|
|
14
|
+
*
|
|
15
|
+
* The route is called directly, as the other route tests do: these are pure
|
|
16
|
+
* request-shape checks that must not reach the filesystem or spawn an omp
|
|
17
|
+
* child, and asserting that is half the point.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
21
|
+
import fs from 'fs';
|
|
22
|
+
import path from 'path';
|
|
23
|
+
import { deleteSession } from '@/server/routes/sessions/delete';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The 404 branch resolves the id by scanning the sessions directory, so the
|
|
27
|
+
* agent dir is pointed at an empty temp tree. Without this the test walks the
|
|
28
|
+
* developer's real `~/.omp/agent/sessions` (read-only, but seconds of I/O and a
|
|
29
|
+
* result that depends on what happens to be installed).
|
|
30
|
+
*/
|
|
31
|
+
const AGENT_DIR = '/tmp/omc-delete-route-test/agent';
|
|
32
|
+
|
|
33
|
+
beforeAll(() => {
|
|
34
|
+
fs.rmSync(AGENT_DIR, { recursive: true, force: true });
|
|
35
|
+
fs.mkdirSync(path.join(AGENT_DIR, 'sessions'), { recursive: true });
|
|
36
|
+
Bun.env.PI_CODING_AGENT_DIR = AGENT_DIR;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(() => {
|
|
40
|
+
delete Bun.env.PI_CODING_AGENT_DIR;
|
|
41
|
+
fs.rmSync('/tmp/omc-delete-route-test', { recursive: true, force: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
function del(sessionId: string): Promise<Response> {
|
|
45
|
+
return deleteSession({
|
|
46
|
+
request: new Request(`http://localhost/api/sessions/${sessionId}`, { method: 'DELETE' }),
|
|
47
|
+
params: { sessionId },
|
|
48
|
+
} as never) as Promise<Response>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('deleteSession refusals', () => {
|
|
52
|
+
test('a pending chat is refused — nothing is stored for it yet', async () => {
|
|
53
|
+
const res = await del('new-1758800000000');
|
|
54
|
+
expect(res.status).toBe(400);
|
|
55
|
+
expect(await res.json()).toMatchObject({ code: 'session_pending' });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('`.` is refused instead of wiping the btw root', async () => {
|
|
59
|
+
const res = await del('.');
|
|
60
|
+
expect(res.status).toBe(400);
|
|
61
|
+
expect(await res.json()).toMatchObject({ error: 'Invalid session id' });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('a traversal is refused', async () => {
|
|
65
|
+
for (const id of ['..', '../x', 'a/b', '..\\a']) {
|
|
66
|
+
expect((await del(id)).status).toBe(400);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('an unknown but well-formed id is a 404, not a 400', async () => {
|
|
71
|
+
// Distinct from the branches above: the request is valid, the session is
|
|
72
|
+
// not there. Collapsing the two would report a typo as a malformed request.
|
|
73
|
+
const res = await del('00000000-0000-0000-0000-000000000000');
|
|
74
|
+
expect(res.status).toBe(404);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('a wrong verb on the path is a 405', async () => {
|
|
78
|
+
const res = (await deleteSession({
|
|
79
|
+
request: new Request('http://localhost/api/sessions/x', { method: 'GET' }),
|
|
80
|
+
params: { sessionId: 'x' },
|
|
81
|
+
} as never)) as Response;
|
|
82
|
+
expect(res.status).toBe(405);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* DELETE /api/sessions/:sessionId — remove a session and everything keyed to
|
|
8
|
+
* it. The mirror of `archiveSession`: archive is reversible and keeps the
|
|
9
|
+
* transcript, this one is the destructive path the user opts into from the
|
|
10
|
+
* sidebar's trash button.
|
|
11
|
+
*
|
|
12
|
+
* Order matters and is enforced here, not by the callers:
|
|
13
|
+
* 1. refuse a session whose omp child is mid-turn (a run, a live subagent, or
|
|
14
|
+
* a dialog the agent is blocked on) — the delete would kill work the user
|
|
15
|
+
* can still see, and omp would flush the file back on shutdown anyway;
|
|
16
|
+
* 2. destroy the child and AWAIT its exit — omp writes session state on
|
|
17
|
+
* shutdown and would otherwise recreate the transcript just removed;
|
|
18
|
+
* 3. purge side questions, then the transcript, artifacts and chamber rows.
|
|
19
|
+
*
|
|
20
|
+
* A pending `new-…` session is refused: it is a client-side placeholder with
|
|
21
|
+
* nothing stored anywhere yet, so deleting it is the same as navigating away.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { json } from '@/server/lib/remix-compat';
|
|
25
|
+
import type { ActionFunctionArgs } from '@/server/lib/remix-compat';
|
|
26
|
+
import { methodNotAllowed } from '@/server/lib/route-adapter';
|
|
27
|
+
import { getRpcSession } from '@/server/lib/omp/rpc/session-registry';
|
|
28
|
+
import { purgeSessionData } from '@/server/lib/omp/session/delete.server';
|
|
29
|
+
import { purgeBtwForSession } from '@/server/lib/btw/purge.server';
|
|
30
|
+
import { isSinglePathSegment } from '@/server/lib/fs/path-segment';
|
|
31
|
+
import { isMockMode } from '@/server/mock.server';
|
|
32
|
+
|
|
33
|
+
export async function deleteSession({ request, params }: ActionFunctionArgs) {
|
|
34
|
+
if (request.method !== 'DELETE') {
|
|
35
|
+
return methodNotAllowed({ request, params });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const sessionId = params.sessionId;
|
|
39
|
+
if (!sessionId) return json({ error: 'Missing session id' }, { status: 400 });
|
|
40
|
+
if (sessionId.startsWith('new-')) {
|
|
41
|
+
return json({ error: 'This chat has not been created yet.', code: 'session_pending' }, { status: 400 });
|
|
42
|
+
}
|
|
43
|
+
// The id names a directory under the sessions root and another under the btw
|
|
44
|
+
// root, so it must be exactly one path segment — see `isSinglePathSegment`
|
|
45
|
+
// for the `.` case that made this load-bearing. Both purges re-check it
|
|
46
|
+
// independently; refusing here is what turns the attempt into a 400.
|
|
47
|
+
if (!isSinglePathSegment(sessionId)) {
|
|
48
|
+
return json({ error: 'Invalid session id' }, { status: 400 });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A live child is the only other writer of the transcript. Busy means a turn,
|
|
52
|
+
// a live subagent, or a dialog omp is parked on — all things the user can
|
|
53
|
+
// still act on, so the delete is refused with a reason rather than silently
|
|
54
|
+
// taking them down.
|
|
55
|
+
const live = getRpcSession(sessionId);
|
|
56
|
+
if (live?.isAlive()) {
|
|
57
|
+
if (live.isBusy()) {
|
|
58
|
+
return json(
|
|
59
|
+
{ error: 'This session is working. Stop the run before deleting it.', code: 'session_busy' },
|
|
60
|
+
{ status: 409 },
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
await live.destroyAndWait();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Side questions live outside the session file and are removed first: their
|
|
67
|
+
// children must be gone before the parent transcript they were snapshotted
|
|
68
|
+
// from disappears.
|
|
69
|
+
const btwTopicsRemoved = isMockMode() ? 0 : await purgeBtwForSession(sessionId);
|
|
70
|
+
const purged = await purgeSessionData(sessionId);
|
|
71
|
+
|
|
72
|
+
if (!purged.found) {
|
|
73
|
+
return json({ error: 'Session not found' }, { status: 404 });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return json({
|
|
77
|
+
success: true,
|
|
78
|
+
sessionId,
|
|
79
|
+
fileRemoved: purged.fileRemoved,
|
|
80
|
+
artifactsRemoved: purged.artifactsRemoved,
|
|
81
|
+
btwTopicsRemoved,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -10,10 +10,15 @@ import {
|
|
|
10
10
|
renameSession,
|
|
11
11
|
} from '@/server/routes/sessions/session';
|
|
12
12
|
import { listSubagents, readSubagentTranscript } from '@/server/routes/sessions/subagents';
|
|
13
|
+
import { deleteSession } from '@/server/routes/sessions/delete';
|
|
13
14
|
|
|
14
15
|
export const sessionsBindings: HandlerBinding[] = [
|
|
15
16
|
...bindingsFor(sessionsList, '/api/sessions/list'),
|
|
16
17
|
...bindingsFor(sessionsFolder, '/api/sessions/:sessionId'),
|
|
18
|
+
// DELETE-only on the same path the folder loader answers GET on: mounting it
|
|
19
|
+
// through `actionBindings` would register a 405 GET fallback that Elysia
|
|
20
|
+
// applies after the loader, replacing the workspace listing with it.
|
|
21
|
+
{ method: 'DELETE', path: '/api/sessions/:sessionId', handler: deleteSession },
|
|
17
22
|
...actionBindings(archiveSession, '/api/sessions/:sessionId/archive'),
|
|
18
23
|
// Per-item queue routes. `actionBindings` mounts ALL four mutating verbs on
|
|
19
24
|
// one path, and Elysia lets a later registration override an earlier one —
|
|
@@ -8,6 +8,18 @@ import type { CommandItem } from '@/shared/types';
|
|
|
8
8
|
import { isRecord } from '@/shared/lib/util/guards';
|
|
9
9
|
import { createSettingsListStore } from '@/server/lib/db/settings-store';
|
|
10
10
|
import { runUtilityCommand } from '@/server/lib/omp/rpc/utility';
|
|
11
|
+
import { resolveDiscoveryScope, type DiscoveryScope } from '@/server/lib/omp/config/scope';
|
|
12
|
+
import {
|
|
13
|
+
deleteCommandFile,
|
|
14
|
+
locateCommandFile,
|
|
15
|
+
readCommandFile,
|
|
16
|
+
writeCommandFile,
|
|
17
|
+
} from '@/server/lib/omp/config/commands';
|
|
18
|
+
import { reloadLiveSessions } from '@/server/lib/omp/session/reload.server';
|
|
19
|
+
|
|
20
|
+
function errorMessage(error: unknown): string {
|
|
21
|
+
return error instanceof Error ? error.message : String(error);
|
|
22
|
+
}
|
|
11
23
|
|
|
12
24
|
const commandsStore = createSettingsListStore<CommandItem>({
|
|
13
25
|
key: 'omp_commands_settings',
|
|
@@ -60,19 +72,21 @@ function toLiveCommandItem(value: unknown): CommandItem | null {
|
|
|
60
72
|
id: `omp-live-${name}`,
|
|
61
73
|
name,
|
|
62
74
|
description: typeof value.description === 'string' ? value.description : '',
|
|
63
|
-
scope: '
|
|
75
|
+
scope: 'system',
|
|
76
|
+
source: value.source,
|
|
64
77
|
template: `/${name}`,
|
|
65
|
-
isBuiltIn:
|
|
78
|
+
isBuiltIn: value.source !== 'file',
|
|
66
79
|
...(aliases.length ? { aliases } : {}),
|
|
67
80
|
...(subcommands.length ? { subcommands } : {}),
|
|
68
81
|
...(hint ? { inputHint: hint } : {}),
|
|
69
82
|
};
|
|
70
83
|
}
|
|
71
84
|
|
|
72
|
-
/** Read live agent commands (builtin/skill/custom/extension/file sources) via
|
|
73
|
-
|
|
85
|
+
/** Read live agent commands (builtin/skill/custom/extension/file sources) via
|
|
86
|
+
* RPC, scoped to `cwd` so a workspace's own `.omp/commands` are discovered. */
|
|
87
|
+
async function loadAgentCommands(cwd: string): Promise<CommandItem[]> {
|
|
74
88
|
try {
|
|
75
|
-
const data = await runUtilityCommand<{ commands?: unknown }>({ type: 'get_available_commands' }, 30_000);
|
|
89
|
+
const data = await runUtilityCommand<{ commands?: unknown }>({ type: 'get_available_commands' }, 30_000, cwd);
|
|
76
90
|
if (!Array.isArray(data.commands)) return [];
|
|
77
91
|
return data.commands.flatMap((command) => {
|
|
78
92
|
const item = toLiveCommandItem(command);
|
|
@@ -83,48 +97,121 @@ async function loadAgentCommands(): Promise<CommandItem[]> {
|
|
|
83
97
|
}
|
|
84
98
|
}
|
|
85
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Attach what the settings pane needs to decide whether a live command is a
|
|
102
|
+
* file the chamber owns: the file backing it (if any), its scope, and its body.
|
|
103
|
+
* `get_available_commands` reports the name, description and hint but not the
|
|
104
|
+
* path, so the answer comes from looking the name up in the roots omp reads.
|
|
105
|
+
*/
|
|
106
|
+
async function withCommandFile(command: CommandItem, scope: DiscoveryScope): Promise<CommandItem> {
|
|
107
|
+
const found = await locateCommandFile(command.name, scope.workspace);
|
|
108
|
+
if (!found) {
|
|
109
|
+
// No markdown file backs this name: a builtin, a `skill:<name>` entry, an
|
|
110
|
+
// extension or a TypeScript custom command. It runs, but nothing here owns
|
|
111
|
+
// a file for it.
|
|
112
|
+
return { ...command, isBuiltIn: true, managed: false };
|
|
113
|
+
}
|
|
114
|
+
const file = await readCommandFile(found.filePath);
|
|
115
|
+
return {
|
|
116
|
+
...command,
|
|
117
|
+
// The file's scope is the truth, not omp's silence about where it found it.
|
|
118
|
+
scope: found.scope,
|
|
119
|
+
isBuiltIn: false,
|
|
120
|
+
managed: found.managed,
|
|
121
|
+
filePath: found.filePath,
|
|
122
|
+
...(file ? { template: file.body, description: file.description || command.description } : {}),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
86
126
|
/** Merge live agent commands ahead of app-local custom commands. */
|
|
87
|
-
async function mergeCommands(custom: CommandItem[]): Promise<CommandItem[]> {
|
|
88
|
-
const live = await loadAgentCommands();
|
|
127
|
+
async function mergeCommands(custom: CommandItem[], scope: DiscoveryScope): Promise<CommandItem[]> {
|
|
128
|
+
const live = await Promise.all((await loadAgentCommands(scope.cwd)).map((c) => withCommandFile(c, scope)));
|
|
89
129
|
const liveNames = new Set(live.map((c) => c.name.toLowerCase()));
|
|
90
130
|
return [...live, ...custom.filter((c) => !liveNames.has(c.name.toLowerCase()))];
|
|
91
131
|
}
|
|
92
132
|
|
|
93
|
-
export async function loader({ request
|
|
133
|
+
export async function loader({ request }: LoaderFunctionArgs) {
|
|
94
134
|
try {
|
|
95
135
|
const db = await getDb();
|
|
96
136
|
const mock = isMockMode();
|
|
97
137
|
const commands = await commandsStore.read(db);
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
138
|
+
if (mock) return json({ commands, isMock: true });
|
|
139
|
+
|
|
140
|
+
// The workspace root decides which project-scope commands/skills exist, so
|
|
141
|
+
// an unscoped read must not silently answer with $HOME's inventory.
|
|
142
|
+
const url = new URL(request.url);
|
|
143
|
+
const scope = await resolveDiscoveryScope(url.searchParams.get('root'), url.searchParams.get('scope'));
|
|
144
|
+
const merged = await mergeCommands(commands, scope);
|
|
145
|
+
return json({ commands: merged, isMock: false });
|
|
146
|
+
} catch (error) {
|
|
101
147
|
const mock = isMockMode();
|
|
102
|
-
return json({ error: error
|
|
148
|
+
return json({ error: errorMessage(error), commands: mock ? DEFAULT_COMMANDS_LIST : [], isMock: mock }, { status: 500 });
|
|
103
149
|
}
|
|
104
150
|
}
|
|
105
151
|
|
|
106
152
|
export async function action({ request, params }: ActionFunctionArgs) {
|
|
107
153
|
try {
|
|
108
154
|
const db = await getDb();
|
|
155
|
+
const url = new URL(request.url);
|
|
109
156
|
|
|
110
157
|
if (request.method === 'DELETE') {
|
|
111
|
-
const url = new URL(request.url);
|
|
112
158
|
const id = url.searchParams.get('id');
|
|
113
159
|
if (!id) return json({ error: 'id is required' }, { status: 400 });
|
|
160
|
+
if (isMockMode()) {
|
|
161
|
+
const list = await commandsStore.remove(db, id);
|
|
162
|
+
return json({ success: true, commands: list });
|
|
163
|
+
}
|
|
114
164
|
|
|
165
|
+
// A live command's id carries its name (`omp-live-<name>`); the pane's
|
|
166
|
+
// delete is only offered for one the chamber owns, and the file is the
|
|
167
|
+
// real thing to remove. A chamber-local row still deletes as a row.
|
|
168
|
+
const name = id.startsWith('omp-live-') ? id.slice('omp-live-'.length) : null;
|
|
169
|
+
const scope = await resolveDiscoveryScope(url.searchParams.get('root'), url.searchParams.get('scope'));
|
|
170
|
+
if (name) {
|
|
171
|
+
await deleteCommandFile(name, scope.workspace);
|
|
172
|
+
await reloadLiveSessions();
|
|
173
|
+
return json({ success: true, commands: await mergeCommands(await commandsStore.read(db), scope) });
|
|
174
|
+
}
|
|
115
175
|
const list = await commandsStore.remove(db, id);
|
|
116
176
|
return json({ success: true, commands: list });
|
|
117
177
|
}
|
|
118
178
|
|
|
119
179
|
if (request.method === 'POST' || request.method === 'PUT') {
|
|
120
180
|
const body = await request.json();
|
|
181
|
+
const scope = await resolveDiscoveryScope(
|
|
182
|
+
url.searchParams.get('root') ?? (typeof body.root === 'string' ? body.root : null),
|
|
183
|
+
url.searchParams.get('scope') ?? (typeof body.scope === 'string' ? body.scope : null),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// Only a markdown file the chamber owns is written as a file. A live
|
|
187
|
+
// command from another provider (an extension command, a `.claude`
|
|
188
|
+
// command, a TypeScript custom command) has no file here, and writing one
|
|
189
|
+
// with the same name would SHADOW it — omp resolves a name present in
|
|
190
|
+
// both places to the file. A brand-new draft (no source) is a create.
|
|
191
|
+
const draft = isRecord(body.command) ? (body.command as unknown as CommandItem) : null;
|
|
192
|
+
if (!isMockMode() && draft && (draft.managed === true || (!draft.source && draft.isBuiltIn !== true))) {
|
|
193
|
+
const previousName = typeof body.previousName === 'string' ? body.previousName : null;
|
|
194
|
+
const target = draft.scope === 'project' ? 'project' : 'user';
|
|
195
|
+
await writeCommandFile({
|
|
196
|
+
scope: target,
|
|
197
|
+
projectDir: scope.workspace,
|
|
198
|
+
previousName,
|
|
199
|
+
name: draft.name,
|
|
200
|
+
description: draft.description ?? '',
|
|
201
|
+
argumentHint: draft.inputHint ?? '',
|
|
202
|
+
body: draft.template ?? '',
|
|
203
|
+
});
|
|
204
|
+
await reloadLiveSessions();
|
|
205
|
+
return json({ success: true, commands: await mergeCommands(await commandsStore.read(db), scope) });
|
|
206
|
+
}
|
|
207
|
+
|
|
121
208
|
const updatedCommands = await commandsStore.upsert(db, body);
|
|
122
209
|
await commandsStore.write(db, updatedCommands);
|
|
123
210
|
return json({ success: true, commands: updatedCommands });
|
|
124
211
|
}
|
|
125
212
|
|
|
126
213
|
return methodNotAllowed({ request, params });
|
|
127
|
-
} catch (error
|
|
128
|
-
return json({ error: error
|
|
214
|
+
} catch (error) {
|
|
215
|
+
return json({ error: errorMessage(error) }, { status: 500 });
|
|
129
216
|
}
|
|
130
217
|
}
|