@openspecui/server 3.4.0 → 3.5.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 (2) hide show
  1. package/dist/index.mjs +416 -10
  2. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { CliExecutor, CodeEditorThemeSchema, ConfigManager, DASHBOARD_METRIC_KEYS, DashboardConfigSchema, GitConfigSchema, HOSTED_SHELL_PROTOCOL_VERSION, MarkdownParser, OPENSPECUI_HOOKS_VERSION, OpenSpecAdapter, OpenSpecWatcher, OpsxConfigSchema, OpsxKernel, PtyClientMessageSchema, ReactiveContext, TerminalConfigSchema, TerminalRendererEngineSchema, getAllTools, getAvailableTools, getConfiguredTools, getDefaultCliCommandString, getDetectedProjectTools, getToolInitStates, getWatcherRuntimeStatus, initWatcherPool, isWatcherPoolInitialized, resolveTerminalShellDefaults, sniffGlobalCli, subscribeWatcherRuntimeStatus } from "@openspecui/core";
1
+ import { CliExecutor, CodeEditorThemeSchema, ConfigManager, CustomSoundHashSchema, DASHBOARD_METRIC_KEYS, DashboardConfigSchema, GitConfigSchema, HOSTED_SHELL_PROTOCOL_VERSION, MarkdownParser, NotificationPublishInputSchema, NotificationSettingsSchema, OPENSPECUI_HOOKS_VERSION, OpenSpecAdapter, OpenSpecWatcher, OpsxConfigSchema, OpsxKernel, PtyClientMessageSchema, ReactiveContext, TerminalConfigSchema, TerminalControlParser, TerminalRendererEngineSchema, getAllTools, getAvailableTools, getConfiguredTools, getDefaultCliCommandString, getDetectedProjectTools, getToolInitStates, getWatcherRuntimeStatus, initWatcherPool, isWatcherPoolInitialized, resolveTerminalShellDefaults, sniffGlobalCli, subscribeWatcherRuntimeStatus, terminalNotificationEventToPublishInput } from "@openspecui/core";
2
2
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
3
3
  import { access, mkdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -10,9 +10,13 @@ import { Hono } from "hono";
10
10
  import { cors } from "hono/cors";
11
11
  import { readFileSync } from "node:fs";
12
12
  import { WebSocketServer } from "ws";
13
+ import { CustomSoundHashSchema as CustomSoundHashSchema$1, CustomSoundIdSchema, CustomSoundMetadataFileSchema, customHashFromSoundId, soundIdFromCustomHash } from "@openspecui/core/sounds";
14
+ import { createHash } from "node:crypto";
15
+ import { homedir } from "node:os";
13
16
  import { EventEmitter } from "node:events";
14
17
  import { execFile } from "node:child_process";
15
18
  import { promisify } from "node:util";
19
+ import { NotificationGroupKeySchema, NotificationPublishInputSchema as NotificationPublishInputSchema$1, getNotificationGroupKey } from "@openspecui/core/notifications";
16
20
  import * as pty from "@lydell/node-pty";
17
21
  import { EventEmitter as EventEmitter$1 } from "events";
18
22
  import { SearchQuerySchema } from "@openspecui/search";
@@ -333,6 +337,135 @@ async function findAvailablePort(startPort, maxAttempts = 10) {
333
337
  throw new Error(`No available port found in range ${startPort}-${startPort + maxAttempts - 1}`);
334
338
  }
335
339
 
340
+ //#endregion
341
+ //#region src/custom-sound-service.ts
342
+ const METADATA_FILE = "metadatas.json";
343
+ function getDefaultSoundsDir() {
344
+ return join(homedir(), ".openspecui", "sounds");
345
+ }
346
+ function isNotFound(error) {
347
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
348
+ }
349
+ function getFallbackName(filename, hash) {
350
+ const trimmed = filename?.trim();
351
+ return trimmed ? trimmed.replace(/\.[^.]*$/, "") : hash.slice(0, 12);
352
+ }
353
+ var CustomSoundService = class {
354
+ soundsDir;
355
+ metadataPath;
356
+ constructor(soundsDir = getDefaultSoundsDir()) {
357
+ this.soundsDir = soundsDir;
358
+ this.metadataPath = join(soundsDir, METADATA_FILE);
359
+ }
360
+ async listAvailable() {
361
+ const metadatas = await this.readMetadatas();
362
+ const available = [];
363
+ for (const metadata of Object.values(metadatas)) if (await this.hasSoundFile(metadata.id)) available.push(metadata);
364
+ return available.sort((left, right) => right.updatedAt - left.updatedAt);
365
+ }
366
+ async upload(input) {
367
+ if (!input.mime.startsWith("audio/")) throw new Error("Only audio files are supported.");
368
+ const hash = hashSoundBytes(input.bytes);
369
+ const now = Date.now();
370
+ const metadatas = await this.readMetadatas();
371
+ const previous = metadatas[hash];
372
+ const metadata = {
373
+ id: hash,
374
+ name: previous?.name ?? getFallbackName(input.name, hash),
375
+ mime: input.mime,
376
+ size: input.bytes.byteLength,
377
+ createdAt: previous?.createdAt ?? now,
378
+ updatedAt: now
379
+ };
380
+ await mkdir(this.soundsDir, { recursive: true });
381
+ await writeFile(this.getSoundPath(hash), input.bytes);
382
+ await this.writeMetadatas({
383
+ ...metadatas,
384
+ [hash]: metadata
385
+ });
386
+ return metadata;
387
+ }
388
+ async rename(id, name) {
389
+ const hash = this.requireCustomHash(id);
390
+ const nextName = name.trim();
391
+ if (!nextName) throw new Error("Sound name is required.");
392
+ const metadatas = await this.readMetadatas();
393
+ const current = metadatas[hash];
394
+ if (!current || !await this.hasSoundFile(hash)) throw new Error("Sound not found.");
395
+ const next = {
396
+ ...current,
397
+ name: nextName,
398
+ updatedAt: Date.now()
399
+ };
400
+ await this.writeMetadatas({
401
+ ...metadatas,
402
+ [hash]: next
403
+ });
404
+ return next;
405
+ }
406
+ async remove(id) {
407
+ const hash = this.requireCustomHash(id);
408
+ const next = { ...await this.readMetadatas() };
409
+ delete next[hash];
410
+ await rm(this.getSoundPath(hash), { force: true });
411
+ await this.writeMetadatas(next);
412
+ }
413
+ async getFile(id) {
414
+ const hash = customHashFromSoundId(id);
415
+ if (!hash) return null;
416
+ const metadata = (await this.readMetadatas())[hash];
417
+ if (!metadata || !await this.hasSoundFile(hash)) return null;
418
+ return {
419
+ metadata,
420
+ data: await readSoundData(this.getSoundPath(hash))
421
+ };
422
+ }
423
+ buildSoundId(hash) {
424
+ return soundIdFromCustomHash(hash);
425
+ }
426
+ requireCustomHash(id) {
427
+ const hash = customHashFromSoundId(id);
428
+ if (!hash) throw new Error("Custom sound id is required.");
429
+ return hash;
430
+ }
431
+ getSoundPath(hash) {
432
+ return join(this.soundsDir, hash);
433
+ }
434
+ async hasSoundFile(hash) {
435
+ try {
436
+ return (await stat(this.getSoundPath(hash))).isFile();
437
+ } catch (error) {
438
+ if (isNotFound(error)) return false;
439
+ throw error;
440
+ }
441
+ }
442
+ async readMetadatas() {
443
+ try {
444
+ const content = await readFile(this.metadataPath, "utf-8");
445
+ const parsed = JSON.parse(content);
446
+ const result = CustomSoundMetadataFileSchema.safeParse(parsed);
447
+ return result.success ? result.data : {};
448
+ } catch (error) {
449
+ if (isNotFound(error)) return {};
450
+ if (error instanceof SyntaxError) return {};
451
+ throw error;
452
+ }
453
+ }
454
+ async writeMetadatas(metadatas) {
455
+ await mkdir(this.soundsDir, { recursive: true });
456
+ await writeFile(this.metadataPath, JSON.stringify(metadatas, null, 2), "utf-8");
457
+ }
458
+ };
459
+ function hashSoundBytes(bytes) {
460
+ return CustomSoundHashSchema$1.parse(createHash("sha256").update(bytes).digest("hex"));
461
+ }
462
+ async function readSoundData(path) {
463
+ const bytes = await readFile(path);
464
+ const data = new ArrayBuffer(bytes.byteLength);
465
+ new Uint8Array(data).set(bytes);
466
+ return data;
467
+ }
468
+
336
469
  //#endregion
337
470
  //#region src/dashboard-overview-service.ts
338
471
  const REBUILD_DEBOUNCE_MS$1 = 250;
@@ -1206,6 +1339,73 @@ async function loadDashboardOverview(ctx, reason = "dashboard-refresh") {
1206
1339
  };
1207
1340
  }
1208
1341
 
1342
+ //#endregion
1343
+ //#region src/notification-service.ts
1344
+ var NotificationService = class {
1345
+ notifications = [];
1346
+ listeners = /* @__PURE__ */ new Set();
1347
+ idCounter = 0;
1348
+ list() {
1349
+ return [...this.notifications];
1350
+ }
1351
+ publish(input) {
1352
+ const parsed = NotificationPublishInputSchema$1.parse(input);
1353
+ const groupKey = getNotificationGroupKey(parsed);
1354
+ const createdAt = parsed.createdAt ?? Date.now();
1355
+ const record = {
1356
+ ...parsed,
1357
+ id: `notification-${Date.now().toString(36)}-${(++this.idCounter).toString(36)}`,
1358
+ createdAt,
1359
+ groupKey
1360
+ };
1361
+ this.notifications = [record, ...this.notifications];
1362
+ this.emit();
1363
+ return record;
1364
+ }
1365
+ markRead(id) {
1366
+ const next = this.notifications.filter((notification) => notification.id !== id);
1367
+ if (next.length === this.notifications.length) return;
1368
+ this.notifications = next;
1369
+ this.emit();
1370
+ }
1371
+ markManyRead(ids) {
1372
+ if (ids.length === 0) return;
1373
+ const idSet = new Set(ids);
1374
+ const next = this.notifications.filter((notification) => !idSet.has(notification.id));
1375
+ if (next.length === this.notifications.length) return;
1376
+ this.notifications = next;
1377
+ this.emit();
1378
+ }
1379
+ clearGroup(groupKey) {
1380
+ const next = this.notifications.filter((notification) => notification.groupKey !== groupKey);
1381
+ if (next.length === this.notifications.length) return;
1382
+ this.notifications = next;
1383
+ this.emit();
1384
+ }
1385
+ clearTerminalSession(sessionId) {
1386
+ const next = this.notifications.filter((notification) => notification.source.type !== "terminal" || notification.source.sessionId !== sessionId);
1387
+ if (next.length === this.notifications.length) return;
1388
+ this.notifications = next;
1389
+ this.emit();
1390
+ }
1391
+ clearAll() {
1392
+ if (this.notifications.length === 0) return;
1393
+ this.notifications = [];
1394
+ this.emit();
1395
+ }
1396
+ subscribe(listener) {
1397
+ this.listeners.add(listener);
1398
+ listener(this.list());
1399
+ return () => {
1400
+ this.listeners.delete(listener);
1401
+ };
1402
+ }
1403
+ emit() {
1404
+ const snapshot = this.list();
1405
+ for (const listener of this.listeners) listener(snapshot);
1406
+ }
1407
+ };
1408
+
1209
1409
  //#endregion
1210
1410
  //#region src/project-recovery-service.ts
1211
1411
  function normalizeWorktreeBranchName(defaultBranch) {
@@ -1454,6 +1654,8 @@ var PtySession = class extends EventEmitter$1 {
1454
1654
  process;
1455
1655
  titleInterval = null;
1456
1656
  lastTitle = "";
1657
+ lastOscIconTitle = "";
1658
+ lastOscWindowTitle = "";
1457
1659
  buffer = [];
1458
1660
  bufferByteLength = 0;
1459
1661
  maxBufferLines;
@@ -1513,6 +1715,18 @@ var PtySession = class extends EventEmitter$1 {
1513
1715
  get title() {
1514
1716
  return this.lastTitle;
1515
1717
  }
1718
+ get targetTitle() {
1719
+ return this.lastOscIconTitle || this.lastOscWindowTitle || this.lastTitle || this.command;
1720
+ }
1721
+ get oscTitle() {
1722
+ return this.lastOscIconTitle || this.lastOscWindowTitle;
1723
+ }
1724
+ setTargetTitle(title, target) {
1725
+ const trimmed = title.trim();
1726
+ if (!trimmed) return;
1727
+ if (target === "icon" || target === "both") this.lastOscIconTitle = trimmed;
1728
+ if (target === "window" || target === "both") this.lastOscWindowTitle = trimmed;
1729
+ }
1516
1730
  appendBuffer(data) {
1517
1731
  let chunk = data;
1518
1732
  if (chunk.length > this.maxBufferBytes) chunk = chunk.slice(-this.maxBufferBytes);
@@ -1621,9 +1835,35 @@ var PtyManager = class {
1621
1835
 
1622
1836
  //#endregion
1623
1837
  //#region src/pty-websocket.ts
1624
- function createPtyWebSocketHandler(ptyManager) {
1838
+ function resolveTerminalTargetTitle(session, title) {
1839
+ return title?.trim() || session.targetTitle || session.title || session.command;
1840
+ }
1841
+ function updateTerminalTargetTitle(session, event) {
1842
+ session.setTargetTitle(event.title, event.target);
1843
+ }
1844
+ function normalizeTerminalNotificationBody(value) {
1845
+ return value.replace(/\s+/g, " ").trim();
1846
+ }
1847
+ function getTerminalNotificationFanoutKey(event) {
1848
+ return normalizeTerminalNotificationBody(event.body) || normalizeTerminalNotificationBody(event.title ?? "");
1849
+ }
1850
+ function coalesceTerminalNotificationFanout(events) {
1851
+ const groups = /* @__PURE__ */ new Map();
1852
+ for (const event of events) {
1853
+ const key = getTerminalNotificationFanoutKey(event);
1854
+ const group = groups.get(key);
1855
+ if (group) group.push(event);
1856
+ else groups.set(key, [event]);
1857
+ }
1858
+ return [...groups.values()].flatMap((group) => {
1859
+ if (new Set(group.map((event) => event.protocol)).size <= 1) return group;
1860
+ return group.find((event) => event.title) ?? group[0] ?? [];
1861
+ });
1862
+ }
1863
+ function createPtyWebSocketHandler(ptyManager, notificationService) {
1625
1864
  return (ws) => {
1626
1865
  const cleanups = /* @__PURE__ */ new Map();
1866
+ const parsers = /* @__PURE__ */ new Map();
1627
1867
  const send = (msg) => {
1628
1868
  if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
1629
1869
  };
@@ -1640,10 +1880,68 @@ function createPtyWebSocketHandler(ptyManager) {
1640
1880
  cleanups.get(sessionId)?.();
1641
1881
  if (opts?.cols && opts?.rows && !session.isExited) session.resize(opts.cols, opts.rows);
1642
1882
  const onData = (data) => {
1643
- send({
1883
+ const parser = parsers.get(sessionId) ?? new TerminalControlParser();
1884
+ parsers.set(sessionId, parser);
1885
+ const parsed = parser.push(data);
1886
+ const notifications = coalesceTerminalNotificationFanout(parsed.events.filter((event) => event.type === "notification"));
1887
+ const notificationsToPublish = new Set(notifications);
1888
+ for (const event of parsed.events) {
1889
+ if (event.type === "bell") {
1890
+ send({
1891
+ type: "bell",
1892
+ sessionId,
1893
+ createdAt: Date.now()
1894
+ });
1895
+ continue;
1896
+ }
1897
+ if (event.type === "notification") {
1898
+ if (!notificationsToPublish.has(event)) continue;
1899
+ notificationService?.publish(terminalNotificationEventToPublishInput({
1900
+ event,
1901
+ sessionId,
1902
+ terminalTitle: resolveTerminalTargetTitle(session)
1903
+ }));
1904
+ continue;
1905
+ }
1906
+ if (event.type === "title") {
1907
+ const previousTargetTitle = session.targetTitle;
1908
+ updateTerminalTargetTitle(session, event);
1909
+ const nextTargetTitle = resolveTerminalTargetTitle(session);
1910
+ if (nextTargetTitle !== previousTargetTitle) send({
1911
+ type: "title",
1912
+ sessionId,
1913
+ title: nextTargetTitle
1914
+ });
1915
+ continue;
1916
+ }
1917
+ if (event.type === "cwd") {
1918
+ send({
1919
+ type: "cwd",
1920
+ sessionId,
1921
+ cwd: event.cwd
1922
+ });
1923
+ continue;
1924
+ }
1925
+ if (event.type === "progress") {
1926
+ send({
1927
+ type: "progress",
1928
+ sessionId,
1929
+ state: event.state,
1930
+ value: event.value
1931
+ });
1932
+ continue;
1933
+ }
1934
+ if (event.type === "prompt-state") send({
1935
+ type: "prompt-state",
1936
+ sessionId,
1937
+ state: event.state,
1938
+ exitCode: event.exitCode
1939
+ });
1940
+ }
1941
+ if (parsed.output) send({
1644
1942
  type: "output",
1645
1943
  sessionId,
1646
- data
1944
+ data: parsed.output
1647
1945
  });
1648
1946
  };
1649
1947
  const onExit = (exitCode) => {
@@ -1655,10 +1953,15 @@ function createPtyWebSocketHandler(ptyManager) {
1655
1953
  };
1656
1954
  const onTitle = (title) => {
1657
1955
  send({
1658
- type: "title",
1956
+ type: "process-title",
1659
1957
  sessionId,
1660
1958
  title
1661
1959
  });
1960
+ send({
1961
+ type: "title",
1962
+ sessionId,
1963
+ title: resolveTerminalTargetTitle(session)
1964
+ });
1662
1965
  };
1663
1966
  session.on("data", onData);
1664
1967
  session.on("exit", onExit);
@@ -1667,6 +1970,7 @@ function createPtyWebSocketHandler(ptyManager) {
1667
1970
  session.removeListener("data", onData);
1668
1971
  session.removeListener("exit", onExit);
1669
1972
  session.removeListener("title", onTitle);
1973
+ parsers.delete(sessionId);
1670
1974
  cleanups.delete(sessionId);
1671
1975
  });
1672
1976
  };
@@ -1730,10 +2034,15 @@ function createPtyWebSocketHandler(ptyManager) {
1730
2034
  data: buffer
1731
2035
  });
1732
2036
  if (session.title) send({
1733
- type: "title",
2037
+ type: "process-title",
1734
2038
  sessionId: session.id,
1735
2039
  title: session.title
1736
2040
  });
2041
+ if (session.title || session.oscTitle) send({
2042
+ type: "title",
2043
+ sessionId: session.id,
2044
+ title: resolveTerminalTargetTitle(session)
2045
+ });
1737
2046
  if (session.isExited) send({
1738
2047
  type: "exit",
1739
2048
  sessionId: session.id,
@@ -2596,6 +2905,59 @@ function createReactiveSubscriptionWithInput(task) {
2596
2905
  const t = initTRPC.context().create();
2597
2906
  const router = t.router;
2598
2907
  const publicProcedure = t.procedure;
2908
+ const notificationsRouter = router({
2909
+ list: publicProcedure.query(({ ctx }) => {
2910
+ return ctx.notificationService.list();
2911
+ }),
2912
+ subscribe: publicProcedure.subscription(({ ctx }) => {
2913
+ return observable((emit) => {
2914
+ const unsubscribe = ctx.notificationService.subscribe((notifications) => {
2915
+ emit.next(notifications);
2916
+ });
2917
+ return () => {
2918
+ unsubscribe();
2919
+ };
2920
+ });
2921
+ }),
2922
+ publish: publicProcedure.input(NotificationPublishInputSchema$1).mutation(({ ctx, input }) => {
2923
+ return ctx.notificationService.publish(input);
2924
+ }),
2925
+ markRead: publicProcedure.input(z.object({ id: z.string().min(1) })).mutation(({ ctx, input }) => {
2926
+ ctx.notificationService.markRead(input.id);
2927
+ return { success: true };
2928
+ }),
2929
+ markManyRead: publicProcedure.input(z.object({ ids: z.array(z.string().min(1)).default([]) })).mutation(({ ctx, input }) => {
2930
+ ctx.notificationService.markManyRead(input.ids);
2931
+ return { success: true };
2932
+ }),
2933
+ clearGroup: publicProcedure.input(z.object({ groupKey: NotificationGroupKeySchema })).mutation(({ ctx, input }) => {
2934
+ ctx.notificationService.clearGroup(input.groupKey);
2935
+ return { success: true };
2936
+ }),
2937
+ clearTerminalSession: publicProcedure.input(z.object({ sessionId: z.string().min(1) })).mutation(({ ctx, input }) => {
2938
+ ctx.notificationService.clearTerminalSession(input.sessionId);
2939
+ return { success: true };
2940
+ }),
2941
+ clearAll: publicProcedure.mutation(({ ctx }) => {
2942
+ ctx.notificationService.clearAll();
2943
+ return { success: true };
2944
+ })
2945
+ });
2946
+ const soundsRouter = router({
2947
+ listCustom: publicProcedure.query(({ ctx }) => {
2948
+ return ctx.customSoundService.listAvailable();
2949
+ }),
2950
+ renameCustom: publicProcedure.input(z.object({
2951
+ id: CustomSoundIdSchema,
2952
+ name: z.string().min(1).max(160)
2953
+ })).mutation(({ ctx, input }) => {
2954
+ return ctx.customSoundService.rename(input.id, input.name);
2955
+ }),
2956
+ deleteCustom: publicProcedure.input(z.object({ id: CustomSoundIdSchema })).mutation(async ({ ctx, input }) => {
2957
+ await ctx.customSoundService.remove(input.id);
2958
+ return { success: true };
2959
+ })
2960
+ });
2599
2961
  const OPSX_CORE_PROFILE_WORKFLOWS = [
2600
2962
  "propose",
2601
2963
  "explore",
@@ -3032,20 +3394,22 @@ const configRouter = router({
3032
3394
  opsx: OpsxConfigSchema.partial().optional(),
3033
3395
  terminal: TerminalConfigSchema.omit({ rendererEngine: true }).partial().extend({ rendererEngine: TerminalRendererEngineSchema.optional() }).optional(),
3034
3396
  dashboard: DashboardConfigSchema.partial().optional(),
3035
- git: GitConfigSchema.partial().optional()
3397
+ git: GitConfigSchema.partial().optional(),
3398
+ notifications: NotificationSettingsSchema.partial().optional()
3036
3399
  })).mutation(async ({ ctx, input }) => {
3037
3400
  const hasCliCommand = input.cli !== void 0 && Object.prototype.hasOwnProperty.call(input.cli, "command");
3038
3401
  const hasCliArgs = input.cli !== void 0 && Object.prototype.hasOwnProperty.call(input.cli, "args");
3039
3402
  if (hasCliCommand && !hasCliArgs) {
3040
3403
  await ctx.configManager.setCliCommand(input.cli?.command ?? "");
3041
- if (input.theme !== void 0 || input.codeEditor !== void 0 || input.appBaseUrl !== void 0 || input.opsx !== void 0 || input.terminal !== void 0 || input.dashboard !== void 0 || input.git !== void 0) await ctx.configManager.writeConfig({
3404
+ if (input.theme !== void 0 || input.codeEditor !== void 0 || input.appBaseUrl !== void 0 || input.opsx !== void 0 || input.terminal !== void 0 || input.dashboard !== void 0 || input.git !== void 0 || input.notifications !== void 0) await ctx.configManager.writeConfig({
3042
3405
  theme: input.theme,
3043
3406
  codeEditor: input.codeEditor,
3044
3407
  appBaseUrl: input.appBaseUrl,
3045
3408
  opsx: input.opsx,
3046
3409
  terminal: input.terminal,
3047
3410
  dashboard: input.dashboard,
3048
- git: input.git
3411
+ git: input.git,
3412
+ notifications: input.notifications
3049
3413
  });
3050
3414
  return { success: true };
3051
3415
  }
@@ -3687,6 +4051,8 @@ const appRouter = router({
3687
4051
  init: initRouter,
3688
4052
  realtime: realtimeRouter,
3689
4053
  config: configRouter,
4054
+ notifications: notificationsRouter,
4055
+ sounds: soundsRouter,
3690
4056
  cli: cliRouter,
3691
4057
  opsx: opsxRouter,
3692
4058
  kv: kvRouter,
@@ -4083,6 +4449,8 @@ function createServer(config) {
4083
4449
  hookRuntime,
4084
4450
  executeCli: (args) => cliExecutor.execute(args)
4085
4451
  });
4452
+ const notificationService = new NotificationService();
4453
+ const customSoundService = new CustomSoundService();
4086
4454
  const watcher = config.enableWatcher !== false ? new OpenSpecWatcher(config.projectDir) : void 0;
4087
4455
  const searchService = new SearchService(adapter, watcher, void 0, documentService);
4088
4456
  const dashboardOverviewService = new DashboardOverviewService((reason) => loadDashboardOverview({
@@ -4111,6 +4479,38 @@ function createServer(config) {
4111
4479
  embeddedUiUrl: buildEmbeddedUiUrlForPort(config.port ?? 3100)
4112
4480
  });
4113
4481
  });
4482
+ app.post("/api/notifications", async (c) => {
4483
+ const body = await c.req.json().catch(() => null);
4484
+ const parsed = NotificationPublishInputSchema.safeParse(body);
4485
+ if (!parsed.success) return c.json({
4486
+ error: "Invalid notification payload",
4487
+ issues: parsed.error.issues
4488
+ }, 400);
4489
+ return c.json(notificationService.publish(parsed.data));
4490
+ });
4491
+ app.post("/api/sounds/custom", async (c) => {
4492
+ const formData = await c.req.formData().catch(() => null);
4493
+ const file = formData?.get("file");
4494
+ const nameValue = formData?.get("name");
4495
+ if (!(file instanceof File)) return c.json({ error: "Audio file is required." }, 400);
4496
+ const metadata = await customSoundService.upload({
4497
+ bytes: new Uint8Array(await file.arrayBuffer()),
4498
+ name: typeof nameValue === "string" ? nameValue : file.name,
4499
+ mime: file.type || "audio/mpeg"
4500
+ });
4501
+ return c.json(metadata);
4502
+ });
4503
+ app.get("/api/sounds/custom/:id", async (c) => {
4504
+ const id = c.req.param("id");
4505
+ const parsedId = CustomSoundHashSchema.safeParse(id);
4506
+ if (!parsedId.success) return c.json({ error: "Sound not found." }, 404);
4507
+ const file = await customSoundService.getFile(`custom:${parsedId.data}`);
4508
+ if (!file) return c.json({ error: "Sound not found." }, 404);
4509
+ return new Response(new Blob([file.data], { type: file.metadata.mime }), { headers: {
4510
+ "Content-Type": file.metadata.mime,
4511
+ "Cache-Control": "private, max-age=31536000, immutable"
4512
+ } });
4513
+ });
4114
4514
  app.use("/trpc/*", async (c) => {
4115
4515
  return await fetchRequestHandler({
4116
4516
  endpoint: "/trpc",
@@ -4126,6 +4526,8 @@ function createServer(config) {
4126
4526
  searchService,
4127
4527
  dashboardOverviewService,
4128
4528
  projectRecoveryService,
4529
+ notificationService,
4530
+ customSoundService,
4129
4531
  gitWorktreeHandoff: config.gitWorktreeHandoff,
4130
4532
  watcher,
4131
4533
  projectDir: config.projectDir
@@ -4142,6 +4544,8 @@ function createServer(config) {
4142
4544
  searchService,
4143
4545
  dashboardOverviewService,
4144
4546
  projectRecoveryService,
4547
+ notificationService,
4548
+ customSoundService,
4145
4549
  gitWorktreeHandoff: config.gitWorktreeHandoff,
4146
4550
  watcher,
4147
4551
  projectDir: config.projectDir
@@ -4157,6 +4561,8 @@ function createServer(config) {
4157
4561
  searchService,
4158
4562
  dashboardOverviewService,
4159
4563
  projectRecoveryService,
4564
+ notificationService,
4565
+ customSoundService,
4160
4566
  hookRuntime,
4161
4567
  watcher,
4162
4568
  createContext,
@@ -4181,7 +4587,7 @@ async function createWebSocketServer(server, httpServer, config) {
4181
4587
  });
4182
4588
  const ptyManager = new PtyManager(config.projectDir);
4183
4589
  const ptyWss = new WebSocketServer({ noServer: true });
4184
- const ptyHandler = createPtyWebSocketHandler(ptyManager);
4590
+ const ptyHandler = createPtyWebSocketHandler(ptyManager, server.notificationService);
4185
4591
  ptyWss.on("connection", ptyHandler);
4186
4592
  httpServer.on("upgrade", (...args) => {
4187
4593
  const [request, socket, head] = args;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openspecui/server",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.mjs",
6
6
  "exports": {