ompchamber 3.5.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 (71) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +357 -353
  3. package/dist/client/static/js/index-7n838fz5.css +1 -0
  4. package/dist/client/static/js/{index-w0cem7n5.js → index-qp2sfmn2.js} +591 -592
  5. package/package.json +1 -1
  6. package/src/cli/lib/commands/status.js +7 -0
  7. package/src/client/components/layout/session-sidebar/about-modal/UpdateLog.tsx +50 -0
  8. package/src/client/components/layout/session-sidebar/about-modal/UpdateSection.tsx +12 -17
  9. package/src/client/components/settings/ProjectSelectorDropdown.tsx +8 -2
  10. package/src/client/components/settings/SettingsSidebarList.tsx +5 -1
  11. package/src/client/components/settings/categories/CommandSettings.tsx +47 -9
  12. package/src/client/components/settings/categories/SkillCatalogSettings.tsx +23 -43
  13. package/src/client/components/settings/categories/SkillSettings.tsx +51 -7
  14. package/src/client/components/settings/categories/command-settings/DetailPane.tsx +109 -103
  15. package/src/client/components/settings/categories/command-settings/SidebarList.tsx +6 -1
  16. package/src/client/components/settings/categories/skill-settings/DetailPane.tsx +130 -31
  17. package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +9 -3
  18. package/src/client/data/settings/command.ts +0 -40
  19. package/src/client/hooks/settings/workspace-roots.ts +99 -0
  20. package/src/client/hooks/ui/updates/index.ts +178 -0
  21. package/src/server/db.server.ts +41 -11
  22. package/src/server/index.ts +17 -1
  23. package/src/server/lib/db/client.ts +17 -0
  24. package/src/server/lib/db/compact-chat-overlay.test.ts +121 -0
  25. package/src/server/lib/db/compact-chat-overlay.ts +90 -0
  26. package/src/server/lib/lifecycle/fd-pressure.test.ts +66 -0
  27. package/src/server/lib/lifecycle/fd-pressure.ts +159 -0
  28. package/src/server/lib/omp/config/commands.test.ts +167 -0
  29. package/src/server/lib/omp/config/commands.ts +195 -0
  30. package/src/server/lib/omp/config/scope.test.ts +49 -0
  31. package/src/server/lib/omp/config/scope.ts +65 -0
  32. package/src/server/lib/omp/config/skills-catalog.ts +35 -3
  33. package/src/server/lib/omp/config/skills.test.ts +105 -0
  34. package/src/server/lib/omp/config/skills.ts +260 -47
  35. package/src/server/lib/omp/rpc/process.ts +22 -14
  36. package/src/server/lib/omp/rpc/session-registry.ts +6 -0
  37. package/src/server/lib/omp/rpc/utility.ts +45 -23
  38. package/src/server/lib/omp/session/merge-stored-anchor.test.ts +85 -0
  39. package/src/server/lib/omp/session/merge-stored-overlay-rows.test.ts +106 -0
  40. package/src/server/lib/omp/session/merge-stored.ts +78 -11
  41. package/src/server/lib/omp/session/reload.server.test.ts +84 -0
  42. package/src/server/lib/omp/session/reload.server.ts +47 -0
  43. package/src/server/lib/omp/session/subagent-presence.ts +40 -9
  44. package/src/server/lib/terminal/liveness.server.ts +65 -0
  45. package/src/server/lib/terminal/runtime.server.ts +70 -117
  46. package/src/server/lib/terminal/session.server.ts +5 -2
  47. package/src/server/lib/terminal/store.ts +72 -0
  48. package/src/server/lib/updates/apply.ts +62 -33
  49. package/src/server/lib/updates/install.ts +9 -2
  50. package/src/server/lib/updates/omp.ts +37 -9
  51. package/src/server/lib/updates/single-flight.test.ts +61 -0
  52. package/src/server/lib/updates/single-flight.ts +68 -0
  53. package/src/server/routes/chat/session.ts +13 -3
  54. package/src/server/routes/health/probe.ts +8 -0
  55. package/src/server/routes/omp/index.ts +0 -2
  56. package/src/server/routes/settings/commands.ts +102 -15
  57. package/src/server/routes/settings/skills.ts +165 -113
  58. package/src/server/routes/terminal/sessions.ts +1 -1
  59. package/src/server/routes/updates/apply.ts +101 -14
  60. package/src/shared/lib/chat/composer/client.ts +27 -18
  61. package/src/shared/lib/chat/composer/scope.test.ts +87 -0
  62. package/src/shared/lib/markdown/katex.ts +11 -1
  63. package/src/shared/lib/markdown/marked.ts +68 -13
  64. package/src/shared/lib/markdown/math.test.ts +53 -1
  65. package/src/shared/lib/updates/progress.test.ts +54 -0
  66. package/src/shared/lib/updates/progress.ts +50 -0
  67. package/src/shared/types/settings/command.ts +15 -2
  68. package/src/shared/types/settings/skill.ts +13 -0
  69. package/dist/client/static/js/index-vx0ernrv.css +0 -1
  70. package/src/client/hooks/ui/updates.ts +0 -108
  71. package/src/server/routes/omp/commands.ts +0 -29
@@ -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'),
@@ -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
  }