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.
Files changed (87) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +387 -383
  3. package/dist/client/static/js/index-7n838fz5.css +1 -0
  4. package/dist/client/static/js/{index-h05z90mw.js → index-qp2sfmn2.js} +614 -615
  5. package/package.json +1 -1
  6. package/src/cli/lib/commands/status.js +7 -0
  7. package/src/client/components/common/session-actions-menu/index.tsx +201 -0
  8. package/src/client/components/common/session-delete-modal/index.tsx +128 -0
  9. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +18 -0
  10. package/src/client/components/layout/session-sidebar/SessionItem.tsx +109 -99
  11. package/src/client/components/layout/session-sidebar/about-modal/UpdateLog.tsx +50 -0
  12. package/src/client/components/layout/session-sidebar/about-modal/UpdateSection.tsx +12 -17
  13. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +18 -0
  14. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +82 -64
  15. package/src/client/components/settings/ProjectSelectorDropdown.tsx +8 -2
  16. package/src/client/components/settings/SettingsSidebarList.tsx +5 -1
  17. package/src/client/components/settings/categories/CommandSettings.tsx +47 -9
  18. package/src/client/components/settings/categories/SkillCatalogSettings.tsx +23 -43
  19. package/src/client/components/settings/categories/SkillSettings.tsx +51 -7
  20. package/src/client/components/settings/categories/command-settings/DetailPane.tsx +109 -103
  21. package/src/client/components/settings/categories/command-settings/SidebarList.tsx +6 -1
  22. package/src/client/components/settings/categories/skill-settings/DetailPane.tsx +130 -31
  23. package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +9 -3
  24. package/src/client/data/settings/command.ts +0 -40
  25. package/src/client/hooks/settings/workspace-roots.ts +99 -0
  26. package/src/client/hooks/ui/updates/index.ts +178 -0
  27. package/src/client/hooks/workspace/session-delete.ts +94 -0
  28. package/src/server/db.server.ts +41 -11
  29. package/src/server/index.ts +17 -1
  30. package/src/server/lib/btw/purge.server.ts +56 -0
  31. package/src/server/lib/db/client.ts +17 -0
  32. package/src/server/lib/db/compact-chat-overlay.test.ts +121 -0
  33. package/src/server/lib/db/compact-chat-overlay.ts +90 -0
  34. package/src/server/lib/fs/path-segment.test.ts +100 -0
  35. package/src/server/lib/fs/path-segment.ts +45 -0
  36. package/src/server/lib/lifecycle/fd-pressure.test.ts +66 -0
  37. package/src/server/lib/lifecycle/fd-pressure.ts +159 -0
  38. package/src/server/lib/omp/config/commands.test.ts +167 -0
  39. package/src/server/lib/omp/config/commands.ts +195 -0
  40. package/src/server/lib/omp/config/scope.test.ts +49 -0
  41. package/src/server/lib/omp/config/scope.ts +65 -0
  42. package/src/server/lib/omp/config/skills-catalog.ts +35 -3
  43. package/src/server/lib/omp/config/skills.test.ts +105 -0
  44. package/src/server/lib/omp/config/skills.ts +260 -47
  45. package/src/server/lib/omp/rpc/process.ts +22 -14
  46. package/src/server/lib/omp/rpc/session-registry.ts +6 -0
  47. package/src/server/lib/omp/rpc/utility.ts +45 -23
  48. package/src/server/lib/omp/session/delete.server.ts +136 -0
  49. package/src/server/lib/omp/session/merge-stored-anchor.test.ts +85 -0
  50. package/src/server/lib/omp/session/merge-stored-overlay-rows.test.ts +106 -0
  51. package/src/server/lib/omp/session/merge-stored.ts +78 -11
  52. package/src/server/lib/omp/session/reader.ts +13 -0
  53. package/src/server/lib/omp/session/reload.server.test.ts +84 -0
  54. package/src/server/lib/omp/session/reload.server.ts +47 -0
  55. package/src/server/lib/omp/session/subagent-presence.ts +40 -9
  56. package/src/server/lib/terminal/liveness.server.ts +65 -0
  57. package/src/server/lib/terminal/runtime.server.ts +70 -117
  58. package/src/server/lib/terminal/session.server.ts +5 -2
  59. package/src/server/lib/terminal/store.ts +72 -0
  60. package/src/server/lib/updates/apply.ts +62 -33
  61. package/src/server/lib/updates/install.ts +9 -2
  62. package/src/server/lib/updates/omp.ts +37 -9
  63. package/src/server/lib/updates/single-flight.test.ts +61 -0
  64. package/src/server/lib/updates/single-flight.ts +68 -0
  65. package/src/server/routes/chat/session.ts +13 -3
  66. package/src/server/routes/health/probe.ts +8 -0
  67. package/src/server/routes/omp/index.ts +0 -2
  68. package/src/server/routes/sessions/delete.test.ts +84 -0
  69. package/src/server/routes/sessions/delete.ts +83 -0
  70. package/src/server/routes/sessions/index.ts +5 -0
  71. package/src/server/routes/settings/commands.ts +102 -15
  72. package/src/server/routes/settings/skills.ts +165 -113
  73. package/src/server/routes/terminal/sessions.ts +1 -1
  74. package/src/server/routes/updates/apply.ts +101 -14
  75. package/src/shared/lib/chat/composer/client.ts +27 -18
  76. package/src/shared/lib/chat/composer/scope.test.ts +87 -0
  77. package/src/shared/lib/markdown/katex.ts +11 -1
  78. package/src/shared/lib/markdown/marked.ts +68 -13
  79. package/src/shared/lib/markdown/math.test.ts +53 -1
  80. package/src/shared/lib/updates/progress.test.ts +54 -0
  81. package/src/shared/lib/updates/progress.ts +50 -0
  82. package/src/shared/lib/workspace/session-state/store.ts +20 -0
  83. package/src/shared/types/settings/command.ts +15 -2
  84. package/src/shared/types/settings/skill.ts +13 -0
  85. package/dist/client/static/js/index-bsxxt89j.css +0 -1
  86. package/src/client/hooks/ui/updates.ts +0 -108
  87. 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(updatedMessages)]
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: updatedMessages,
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: 'user',
75
+ scope: 'system',
76
+ source: value.source,
64
77
  template: `/${name}`,
65
- isBuiltIn: true,
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 RPC. */
73
- async function loadAgentCommands(): Promise<CommandItem[]> {
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: _request }: LoaderFunctionArgs) {
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
- const merged = mock ? commands : await mergeCommands(commands);
99
- return json({ commands: merged, isMock: mock });
100
- } catch (error: any) {
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.message, commands: mock ? DEFAULT_COMMANDS_LIST : [], isMock: mock }, { status: 500 });
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: any) {
128
- return json({ error: error.message }, { status: 500 });
214
+ } catch (error) {
215
+ return json({ error: errorMessage(error) }, { status: 500 });
129
216
  }
130
217
  }