@openspecui/server 0.9.0 → 1.0.2

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 +1070 -127
  2. package/package.json +6 -4
package/dist/index.mjs CHANGED
@@ -4,12 +4,179 @@ import { cors } from "hono/cors";
4
4
  import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
5
5
  import { applyWSSHandler } from "@trpc/server/adapters/ws";
6
6
  import { WebSocketServer } from "ws";
7
- import { CliExecutor, ConfigManager, OpenSpecAdapter, OpenSpecWatcher, ReactiveContext, getAllTools, getAvailableTools, getConfiguredTools, getDefaultCliCommandString, initWatcherPool, sniffGlobalCli } from "@openspecui/core";
7
+ import { ApplyInstructionsSchema, ArtifactInstructionsSchema, ChangeStatusSchema, CliExecutor, ConfigManager, OpenSpecAdapter, OpenSpecWatcher, OpsxKernel, PtyClientMessageSchema, ReactiveContext, SchemaDetailSchema, SchemaInfoSchema, SchemaResolutionSchema, TemplatesSchema, getAllTools, getAvailableTools, getConfiguredTools, getDefaultCliCommandString, initWatcherPool, isWatcherPoolInitialized, reactiveReadDir, reactiveReadFile, reactiveStat, sniffGlobalCli } from "@openspecui/core";
8
8
  import { initTRPC } from "@trpc/server";
9
9
  import { observable } from "@trpc/server/observable";
10
+ import { mkdir, rm, writeFile } from "node:fs/promises";
11
+ import { dirname, join, matchesGlob, relative, resolve, sep } from "node:path";
10
12
  import { z } from "zod";
13
+ import { parse } from "yaml";
14
+ import { EventEmitter } from "node:events";
11
15
  import { createServer as createServer$1 } from "node:net";
16
+ import * as pty from "@lydell/node-pty";
17
+ import { EventEmitter as EventEmitter$1 } from "events";
12
18
 
19
+ //#region src/cli-stream-observable.ts
20
+ /**
21
+ * 创建安全的 CLI 流式 observable
22
+ *
23
+ * 解决的问题:
24
+ * 1. 防止在 emit.complete() 之后调用 emit.next()(会导致 "Controller is already closed" 错误)
25
+ * 2. 统一的错误处理,防止未捕获的异常导致服务器崩溃
26
+ * 3. 确保取消时正确清理资源
27
+ *
28
+ * @param startStream 启动流的函数,接收 onEvent 回调,返回取消函数的 Promise
29
+ */
30
+ function createCliStreamObservable(startStream) {
31
+ return observable((emit) => {
32
+ let cancel;
33
+ let completed = false;
34
+ /**
35
+ * 安全的事件处理器
36
+ * - 检查是否已完成,防止重复调用
37
+ * - 使用 try-catch 防止异常导致服务器崩溃
38
+ */
39
+ const safeEventHandler = (event) => {
40
+ if (completed) return;
41
+ try {
42
+ emit.next(event);
43
+ if (event.type === "exit") {
44
+ completed = true;
45
+ emit.complete();
46
+ }
47
+ } catch (err) {
48
+ console.error("[CLI Stream] Error emitting event:", err);
49
+ if (!completed) {
50
+ completed = true;
51
+ try {
52
+ emit.error(err instanceof Error ? err : new Error(String(err)));
53
+ } catch {}
54
+ }
55
+ }
56
+ };
57
+ startStream(safeEventHandler).then((cancelFn) => {
58
+ cancel = cancelFn;
59
+ }).catch((err) => {
60
+ console.error("[CLI Stream] Error starting stream:", err);
61
+ if (!completed) {
62
+ completed = true;
63
+ try {
64
+ emit.error(err instanceof Error ? err : new Error(String(err)));
65
+ } catch {}
66
+ }
67
+ });
68
+ return () => {
69
+ completed = true;
70
+ cancel?.();
71
+ };
72
+ });
73
+ }
74
+
75
+ //#endregion
76
+ //#region src/opsx-schema.ts
77
+ const SchemaYamlArtifactSchema = z.object({
78
+ id: z.string(),
79
+ generates: z.string(),
80
+ description: z.string().optional(),
81
+ template: z.string().optional(),
82
+ instruction: z.string().optional(),
83
+ requires: z.array(z.string()).optional()
84
+ });
85
+ const SchemaYamlSchema = z.object({
86
+ name: z.string(),
87
+ version: z.union([z.string(), z.number()]).optional(),
88
+ description: z.string().optional(),
89
+ artifacts: z.array(SchemaYamlArtifactSchema),
90
+ apply: z.object({
91
+ requires: z.array(z.string()).optional(),
92
+ tracks: z.string().optional(),
93
+ instruction: z.string().optional()
94
+ }).optional()
95
+ });
96
+ function parseSchemaYaml(content) {
97
+ const raw = parse(content);
98
+ const parsed = SchemaYamlSchema.safeParse(raw);
99
+ if (!parsed.success) throw new Error(`Invalid schema.yaml: ${parsed.error.message}`);
100
+ const { artifacts, apply, name, description, version } = parsed.data;
101
+ const detail = {
102
+ name,
103
+ description,
104
+ version,
105
+ artifacts: artifacts.map((artifact) => ({
106
+ id: artifact.id,
107
+ outputPath: artifact.generates,
108
+ description: artifact.description,
109
+ template: artifact.template,
110
+ instruction: artifact.instruction,
111
+ requires: artifact.requires ?? []
112
+ })),
113
+ applyRequires: apply?.requires ?? [],
114
+ applyTracks: apply?.tracks,
115
+ applyInstruction: apply?.instruction
116
+ };
117
+ const validated = SchemaDetailSchema.safeParse(detail);
118
+ if (!validated.success) throw new Error(`Invalid schema detail: ${validated.error.message}`);
119
+ return validated.data;
120
+ }
121
+
122
+ //#endregion
123
+ //#region src/reactive-kv.ts
124
+ /**
125
+ * In-memory reactive key-value store.
126
+ *
127
+ * - No disk persistence — devices own their own storage (e.g. IndexedDB).
128
+ * - Emits 'change' events when values are set or deleted.
129
+ * - Designed for cross-device sync scenarios where the server acts as a
130
+ * transient rendezvous point.
131
+ */
132
+ var ReactiveKV = class {
133
+ store = /* @__PURE__ */ new Map();
134
+ emitter = new EventEmitter();
135
+ constructor() {
136
+ this.emitter.setMaxListeners(200);
137
+ }
138
+ get(key) {
139
+ return this.store.get(key);
140
+ }
141
+ set(key, value) {
142
+ this.store.set(key, value);
143
+ this.emitter.emit("change", key);
144
+ this.emitter.emit(`change:${key}`, value);
145
+ }
146
+ delete(key) {
147
+ const existed = this.store.delete(key);
148
+ if (existed) {
149
+ this.emitter.emit("change", key);
150
+ this.emitter.emit(`change:${key}`, void 0);
151
+ }
152
+ return existed;
153
+ }
154
+ has(key) {
155
+ return this.store.has(key);
156
+ }
157
+ keys() {
158
+ return [...this.store.keys()];
159
+ }
160
+ /** Subscribe to changes for a specific key. Returns unsubscribe function. */
161
+ onKey(key, listener) {
162
+ const event = `change:${key}`;
163
+ this.emitter.on(event, listener);
164
+ return () => {
165
+ this.emitter.off(event, listener);
166
+ };
167
+ }
168
+ /** Subscribe to all changes. Returns unsubscribe function. */
169
+ onChange(listener) {
170
+ this.emitter.on("change", listener);
171
+ return () => {
172
+ this.emitter.off("change", listener);
173
+ };
174
+ }
175
+ };
176
+ /** Singleton instance shared across the server lifetime */
177
+ const reactiveKV = new ReactiveKV();
178
+
179
+ //#endregion
13
180
  //#region src/reactive-subscription.ts
14
181
  /**
15
182
  * 创建响应式订阅
@@ -67,85 +234,187 @@ function createReactiveSubscriptionWithInput(task) {
67
234
  };
68
235
  }
69
236
 
70
- //#endregion
71
- //#region src/cli-stream-observable.ts
72
- /**
73
- * 创建安全的 CLI 流式 observable
74
- *
75
- * 解决的问题:
76
- * 1. 防止在 emit.complete() 之后调用 emit.next()(会导致 "Controller is already closed" 错误)
77
- * 2. 统一的错误处理,防止未捕获的异常导致服务器崩溃
78
- * 3. 确保取消时正确清理资源
79
- *
80
- * @param startStream 启动流的函数,接收 onEvent 回调,返回取消函数的 Promise
81
- */
82
- function createCliStreamObservable(startStream) {
83
- return observable((emit) => {
84
- let cancel;
85
- let completed = false;
86
- /**
87
- * 安全的事件处理器
88
- * - 检查是否已完成,防止重复调用
89
- * - 使用 try-catch 防止异常导致服务器崩溃
90
- */
91
- const safeEventHandler = (event) => {
92
- if (completed) return;
93
- try {
94
- emit.next(event);
95
- if (event.type === "exit") {
96
- completed = true;
97
- emit.complete();
98
- }
99
- } catch (err) {
100
- console.error("[CLI Stream] Error emitting event:", err);
101
- if (!completed) {
102
- completed = true;
103
- try {
104
- emit.error(err instanceof Error ? err : new Error(String(err)));
105
- } catch {}
106
- }
107
- }
108
- };
109
- startStream(safeEventHandler).then((cancelFn) => {
110
- cancel = cancelFn;
111
- }).catch((err) => {
112
- console.error("[CLI Stream] Error starting stream:", err);
113
- if (!completed) {
114
- completed = true;
115
- try {
116
- emit.error(err instanceof Error ? err : new Error(String(err)));
117
- } catch {}
118
- }
119
- });
120
- return () => {
121
- completed = true;
122
- cancel?.();
123
- };
124
- });
125
- }
126
-
127
237
  //#endregion
128
238
  //#region src/router.ts
129
239
  const t = initTRPC.context().create();
130
240
  const router = t.router;
131
241
  const publicProcedure = t.procedure;
132
- /**
133
- * Dashboard router - overview and status
134
- */
135
- const dashboardRouter = router({
136
- getData: publicProcedure.query(async ({ ctx }) => {
137
- return ctx.adapter.getDashboardData();
138
- }),
139
- isInitialized: publicProcedure.query(async ({ ctx }) => {
140
- return ctx.adapter.isInitialized();
141
- }),
142
- subscribe: publicProcedure.subscription(({ ctx }) => {
143
- return createReactiveSubscription(() => ctx.adapter.getDashboardData());
144
- }),
145
- subscribeInitialized: publicProcedure.subscription(({ ctx }) => {
146
- return createReactiveSubscription(() => ctx.adapter.isInitialized());
147
- })
148
- });
242
+ function requireChangeId(changeId) {
243
+ if (!changeId) throw new Error("change is required");
244
+ return changeId;
245
+ }
246
+ function ensureEditableSource(source, label) {
247
+ if (source === "package") throw new Error(`${label} is read-only (package source)`);
248
+ }
249
+ function parseCliJson(raw, schema, label) {
250
+ const trimmed = raw.trim();
251
+ if (!trimmed) throw new Error(`${label} returned empty output`);
252
+ let parsed;
253
+ try {
254
+ parsed = JSON.parse(trimmed);
255
+ } catch (err) {
256
+ const message = err instanceof Error ? err.message : String(err);
257
+ throw new Error(`${label} returned invalid JSON: ${message}`);
258
+ }
259
+ const result = schema.safeParse(parsed);
260
+ if (!result.success) throw new Error(`${label} returned unexpected JSON: ${result.error.message}`);
261
+ return result.data;
262
+ }
263
+ function resolveEntryPath(root, entryPath) {
264
+ const normalizedRoot = resolve(root);
265
+ const resolvedPath = resolve(normalizedRoot, entryPath);
266
+ const rootPrefix = normalizedRoot + sep;
267
+ if (resolvedPath !== normalizedRoot && !resolvedPath.startsWith(rootPrefix)) throw new Error("Invalid path: outside schema root");
268
+ return resolvedPath;
269
+ }
270
+ function toRelativePath(root, absolutePath) {
271
+ return relative(root, absolutePath).split(sep).join("/");
272
+ }
273
+ async function readEntriesUnderRoot(root) {
274
+ if (!(await reactiveStat(root))?.isDirectory) return [];
275
+ const collectEntries = async (dir) => {
276
+ const names = await reactiveReadDir(dir, { includeHidden: false });
277
+ const entries = [];
278
+ for (const name of names) {
279
+ const fullPath = join(dir, name);
280
+ const statInfo = await reactiveStat(fullPath);
281
+ if (!statInfo) continue;
282
+ const relativePath = toRelativePath(root, fullPath);
283
+ if (statInfo.isDirectory) {
284
+ entries.push({
285
+ path: relativePath,
286
+ type: "directory"
287
+ });
288
+ entries.push(...await collectEntries(fullPath));
289
+ } else {
290
+ const content = await reactiveReadFile(fullPath);
291
+ const size = content ? Buffer.byteLength(content, "utf-8") : void 0;
292
+ entries.push({
293
+ path: relativePath,
294
+ type: "file",
295
+ content: content ?? void 0,
296
+ size
297
+ });
298
+ }
299
+ }
300
+ return entries;
301
+ };
302
+ return collectEntries(root);
303
+ }
304
+ async function touchOpsxProjectDeps(projectDir) {
305
+ const openspecDir = join(projectDir, "openspec");
306
+ await reactiveReadFile(join(openspecDir, "config.yaml"));
307
+ const schemaRoot = join(openspecDir, "schemas");
308
+ const schemaDirs = await reactiveReadDir(schemaRoot, {
309
+ directoriesOnly: true,
310
+ includeHidden: true
311
+ });
312
+ await Promise.all(schemaDirs.map((name) => reactiveReadFile(join(schemaRoot, name, "schema.yaml"))));
313
+ await reactiveReadDir(join(openspecDir, "changes"), {
314
+ directoriesOnly: true,
315
+ includeHidden: true,
316
+ exclude: ["archive"]
317
+ });
318
+ }
319
+ async function touchOpsxChangeDeps(projectDir, changeId) {
320
+ const changeDir = join(projectDir, "openspec", "changes", changeId);
321
+ await reactiveReadDir(changeDir, { includeHidden: true });
322
+ await reactiveReadFile(join(changeDir, ".openspec.yaml"));
323
+ }
324
+ async function readGlobArtifactFiles(projectDir, changeId, outputPath) {
325
+ return (await readEntriesUnderRoot(join(projectDir, "openspec", "changes", changeId))).filter((entry) => entry.type === "file" && matchesGlob(entry.path, outputPath)).map((entry) => ({
326
+ path: entry.path,
327
+ type: "file",
328
+ content: entry.content ?? ""
329
+ }));
330
+ }
331
+ async function fetchOpsxStatus(ctx, input) {
332
+ const changeId = requireChangeId(input.change);
333
+ await touchOpsxProjectDeps(ctx.projectDir);
334
+ await touchOpsxChangeDeps(ctx.projectDir, changeId);
335
+ const args = [
336
+ "status",
337
+ "--json",
338
+ "--change",
339
+ changeId
340
+ ];
341
+ if (input.schema) args.push("--schema", input.schema);
342
+ const result = await ctx.cliExecutor.execute(args);
343
+ if (!result.success) throw new Error(result.stderr || `openspec status failed (exit ${result.exitCode ?? "null"})`);
344
+ const status = parseCliJson(result.stdout, ChangeStatusSchema, "openspec status");
345
+ const changeRelDir = `openspec/changes/${changeId}`;
346
+ for (const artifact of status.artifacts) artifact.relativePath = `${changeRelDir}/${artifact.outputPath}`;
347
+ return status;
348
+ }
349
+ async function fetchOpsxStatusList(ctx) {
350
+ const changeIds = await reactiveReadDir(join(ctx.projectDir, "openspec", "changes"), {
351
+ directoriesOnly: true,
352
+ includeHidden: false,
353
+ exclude: ["archive"]
354
+ });
355
+ return await Promise.all(changeIds.map((changeId) => fetchOpsxStatus(ctx, { change: changeId })));
356
+ }
357
+ async function fetchOpsxInstructions(ctx, input) {
358
+ const changeId = requireChangeId(input.change);
359
+ await touchOpsxProjectDeps(ctx.projectDir);
360
+ await touchOpsxChangeDeps(ctx.projectDir, changeId);
361
+ const args = [
362
+ "instructions",
363
+ input.artifact,
364
+ "--json",
365
+ "--change",
366
+ changeId
367
+ ];
368
+ if (input.schema) args.push("--schema", input.schema);
369
+ const result = await ctx.cliExecutor.execute(args);
370
+ if (!result.success) throw new Error(result.stderr || `openspec instructions failed (exit ${result.exitCode ?? "null"})`);
371
+ return parseCliJson(result.stdout, ArtifactInstructionsSchema, "openspec instructions");
372
+ }
373
+ async function fetchOpsxApplyInstructions(ctx, input) {
374
+ const changeId = requireChangeId(input.change);
375
+ await touchOpsxProjectDeps(ctx.projectDir);
376
+ await touchOpsxChangeDeps(ctx.projectDir, changeId);
377
+ const args = [
378
+ "instructions",
379
+ "apply",
380
+ "--json",
381
+ "--change",
382
+ changeId
383
+ ];
384
+ if (input.schema) args.push("--schema", input.schema);
385
+ const result = await ctx.cliExecutor.execute(args);
386
+ if (!result.success) throw new Error(result.stderr || `openspec instructions apply failed (exit ${result.exitCode ?? "null"})`);
387
+ return parseCliJson(result.stdout, ApplyInstructionsSchema, "openspec instructions apply");
388
+ }
389
+ async function fetchOpsxSchemas(ctx) {
390
+ await touchOpsxProjectDeps(ctx.projectDir);
391
+ const result = await ctx.cliExecutor.schemas();
392
+ if (!result.success) throw new Error(result.stderr || `openspec schemas failed (exit ${result.exitCode ?? "null"})`);
393
+ return parseCliJson(result.stdout, z.array(SchemaInfoSchema), "openspec schemas");
394
+ }
395
+ async function fetchOpsxSchemaResolution(ctx, name) {
396
+ await touchOpsxProjectDeps(ctx.projectDir);
397
+ const result = await ctx.cliExecutor.schemaWhich(name);
398
+ if (!result.success) throw new Error(result.stderr || `openspec schema which failed (exit ${result.exitCode ?? "null"})`);
399
+ return parseCliJson(result.stdout, SchemaResolutionSchema, "openspec schema which");
400
+ }
401
+ async function fetchOpsxTemplates(ctx, schema) {
402
+ await touchOpsxProjectDeps(ctx.projectDir);
403
+ const result = await ctx.cliExecutor.templates(schema);
404
+ if (!result.success) throw new Error(result.stderr || `openspec templates failed (exit ${result.exitCode ?? "null"})`);
405
+ return parseCliJson(result.stdout, TemplatesSchema, "openspec templates");
406
+ }
407
+ async function fetchOpsxTemplateContents(ctx, schema) {
408
+ const templates = await fetchOpsxTemplates(ctx, schema);
409
+ const entries = await Promise.all(Object.entries(templates).map(async ([artifactId, info]) => {
410
+ return [artifactId, {
411
+ content: await reactiveReadFile(info.path),
412
+ path: info.path,
413
+ source: info.source
414
+ }];
415
+ }));
416
+ return Object.fromEntries(entries);
417
+ }
149
418
  /**
150
419
  * Spec router - spec CRUD operations
151
420
  */
@@ -223,17 +492,8 @@ const changeRouter = router({
223
492
  if (!await ctx.adapter.toggleTask(input.changeId, input.taskIndex, input.completed)) throw new Error(`Failed to toggle task ${input.taskIndex} in change ${input.changeId}`);
224
493
  return { success: true };
225
494
  }),
226
- subscribe: publicProcedure.subscription(({ ctx }) => {
227
- return createReactiveSubscription(() => ctx.adapter.listChangesWithMeta());
228
- }),
229
- subscribeOne: publicProcedure.input(z.object({ id: z.string() })).subscription(({ ctx, input }) => {
230
- return createReactiveSubscriptionWithInput((id) => ctx.adapter.readChange(id))(input.id);
231
- }),
232
495
  subscribeFiles: publicProcedure.input(z.object({ id: z.string() })).subscription(({ ctx, input }) => {
233
496
  return createReactiveSubscriptionWithInput((id) => ctx.adapter.readChangeFiles(id))(input.id);
234
- }),
235
- subscribeRaw: publicProcedure.input(z.object({ id: z.string() })).subscription(({ ctx, input }) => {
236
- return createReactiveSubscriptionWithInput((id) => ctx.adapter.readChangeRaw(id))(input.id);
237
497
  })
238
498
  });
239
499
  /**
@@ -244,31 +504,6 @@ const initRouter = router({ init: publicProcedure.mutation(async ({ ctx }) => {
244
504
  return { success: true };
245
505
  }) });
246
506
  /**
247
- * Project router - project-level files (project.md, AGENTS.md)
248
- */
249
- const projectRouter = router({
250
- getProjectMd: publicProcedure.query(async ({ ctx }) => {
251
- return ctx.adapter.readProjectMd();
252
- }),
253
- getAgentsMd: publicProcedure.query(async ({ ctx }) => {
254
- return ctx.adapter.readAgentsMd();
255
- }),
256
- saveProjectMd: publicProcedure.input(z.object({ content: z.string() })).mutation(async ({ ctx, input }) => {
257
- await ctx.adapter.writeProjectMd(input.content);
258
- return { success: true };
259
- }),
260
- saveAgentsMd: publicProcedure.input(z.object({ content: z.string() })).mutation(async ({ ctx, input }) => {
261
- await ctx.adapter.writeAgentsMd(input.content);
262
- return { success: true };
263
- }),
264
- subscribeProjectMd: publicProcedure.subscription(({ ctx }) => {
265
- return createReactiveSubscription(() => ctx.adapter.readProjectMd());
266
- }),
267
- subscribeAgentsMd: publicProcedure.subscription(({ ctx }) => {
268
- return createReactiveSubscription(() => ctx.adapter.readAgentsMd());
269
- })
270
- });
271
- /**
272
507
  * Archive router - archived changes
273
508
  */
274
509
  const archiveRouter = router({
@@ -378,20 +613,40 @@ const configRouter = router({
378
613
  return getDefaultCliCommandString();
379
614
  }),
380
615
  update: publicProcedure.input(z.object({
381
- cli: z.object({ command: z.string() }).optional(),
382
- ui: z.object({ theme: z.enum([
616
+ cli: z.object({
617
+ command: z.string().nullable().optional(),
618
+ args: z.array(z.string()).nullable().optional()
619
+ }).optional(),
620
+ theme: z.enum([
383
621
  "light",
384
622
  "dark",
385
623
  "system"
386
- ]) }).optional()
624
+ ]).optional(),
625
+ terminal: z.object({
626
+ fontSize: z.number().min(8).max(32).optional(),
627
+ fontFamily: z.string().optional(),
628
+ cursorBlink: z.boolean().optional(),
629
+ cursorStyle: z.enum([
630
+ "block",
631
+ "underline",
632
+ "bar"
633
+ ]).optional(),
634
+ scrollback: z.number().min(0).max(1e5).optional()
635
+ }).optional()
387
636
  })).mutation(async ({ ctx, input }) => {
637
+ const hasCliCommand = input.cli !== void 0 && Object.prototype.hasOwnProperty.call(input.cli, "command");
638
+ const hasCliArgs = input.cli !== void 0 && Object.prototype.hasOwnProperty.call(input.cli, "args");
639
+ if (hasCliCommand && !hasCliArgs) {
640
+ await ctx.configManager.setCliCommand(input.cli?.command ?? "");
641
+ if (input.theme !== void 0 || input.terminal !== void 0) await ctx.configManager.writeConfig({
642
+ theme: input.theme,
643
+ terminal: input.terminal
644
+ });
645
+ return { success: true };
646
+ }
388
647
  await ctx.configManager.writeConfig(input);
389
648
  return { success: true };
390
649
  }),
391
- setCliCommand: publicProcedure.input(z.object({ command: z.string() })).mutation(async ({ ctx, input }) => {
392
- await ctx.configManager.setCliCommand(input.command);
393
- return { success: true };
394
- }),
395
650
  subscribe: publicProcedure.subscription(({ ctx }) => {
396
651
  return createReactiveSubscription(() => ctx.configManager.readConfig());
397
652
  })
@@ -501,18 +756,348 @@ const cliRouter = router({
501
756
  })
502
757
  });
503
758
  /**
759
+ * OPSX router - CLI-driven workflow data
760
+ */
761
+ const opsxRouter = router({
762
+ status: publicProcedure.input(z.object({
763
+ change: z.string().optional(),
764
+ schema: z.string().optional()
765
+ })).query(async ({ ctx, input }) => {
766
+ return fetchOpsxStatus(ctx, input);
767
+ }),
768
+ subscribeStatus: publicProcedure.input(z.object({
769
+ change: z.string().optional(),
770
+ schema: z.string().optional()
771
+ })).subscription(({ ctx, input }) => {
772
+ return createReactiveSubscription(() => fetchOpsxStatus(ctx, input));
773
+ }),
774
+ statusList: publicProcedure.query(async ({ ctx }) => {
775
+ return fetchOpsxStatusList(ctx);
776
+ }),
777
+ subscribeStatusList: publicProcedure.subscription(({ ctx }) => {
778
+ return createReactiveSubscription(() => fetchOpsxStatusList(ctx));
779
+ }),
780
+ instructions: publicProcedure.input(z.object({
781
+ change: z.string().optional(),
782
+ artifact: z.string(),
783
+ schema: z.string().optional()
784
+ })).query(async ({ ctx, input }) => {
785
+ return fetchOpsxInstructions(ctx, input);
786
+ }),
787
+ subscribeInstructions: publicProcedure.input(z.object({
788
+ change: z.string().optional(),
789
+ artifact: z.string(),
790
+ schema: z.string().optional()
791
+ })).subscription(({ ctx, input }) => {
792
+ return createReactiveSubscription(() => fetchOpsxInstructions(ctx, input));
793
+ }),
794
+ applyInstructions: publicProcedure.input(z.object({
795
+ change: z.string().optional(),
796
+ schema: z.string().optional()
797
+ })).query(async ({ ctx, input }) => {
798
+ return fetchOpsxApplyInstructions(ctx, input);
799
+ }),
800
+ subscribeApplyInstructions: publicProcedure.input(z.object({
801
+ change: z.string().optional(),
802
+ schema: z.string().optional()
803
+ })).subscription(({ ctx, input }) => {
804
+ return createReactiveSubscription(() => fetchOpsxApplyInstructions(ctx, input));
805
+ }),
806
+ schemas: publicProcedure.query(async ({ ctx }) => {
807
+ return fetchOpsxSchemas(ctx);
808
+ }),
809
+ subscribeSchemas: publicProcedure.subscription(({ ctx }) => {
810
+ return createReactiveSubscription(() => fetchOpsxSchemas(ctx));
811
+ }),
812
+ templates: publicProcedure.input(z.object({ schema: z.string().optional() }).optional()).query(async ({ ctx, input }) => {
813
+ return fetchOpsxTemplates(ctx, input?.schema);
814
+ }),
815
+ subscribeTemplates: publicProcedure.input(z.object({ schema: z.string().optional() }).optional()).subscription(({ ctx, input }) => {
816
+ return createReactiveSubscription(() => fetchOpsxTemplates(ctx, input?.schema));
817
+ }),
818
+ templateContents: publicProcedure.input(z.object({ schema: z.string().optional() }).optional()).query(async ({ ctx, input }) => {
819
+ return fetchOpsxTemplateContents(ctx, input?.schema);
820
+ }),
821
+ subscribeTemplateContents: publicProcedure.input(z.object({ schema: z.string().optional() }).optional()).subscription(({ ctx, input }) => {
822
+ return createReactiveSubscription(() => fetchOpsxTemplateContents(ctx, input?.schema));
823
+ }),
824
+ schemaResolution: publicProcedure.input(z.object({ name: z.string() })).query(async ({ ctx, input }) => {
825
+ return fetchOpsxSchemaResolution(ctx, input.name);
826
+ }),
827
+ subscribeSchemaResolution: publicProcedure.input(z.object({ name: z.string() })).subscription(({ ctx, input }) => {
828
+ return createReactiveSubscription(() => fetchOpsxSchemaResolution(ctx, input.name));
829
+ }),
830
+ schemaDetail: publicProcedure.input(z.object({ name: z.string() })).query(async ({ ctx, input }) => {
831
+ await touchOpsxProjectDeps(ctx.projectDir);
832
+ const schemaPath = join((await fetchOpsxSchemaResolution(ctx, input.name)).path, "schema.yaml");
833
+ const content = await reactiveReadFile(schemaPath);
834
+ if (!content) throw new Error(`schema.yaml not found at ${schemaPath}`);
835
+ return parseSchemaYaml(content);
836
+ }),
837
+ subscribeSchemaDetail: publicProcedure.input(z.object({ name: z.string() })).subscription(({ ctx, input }) => {
838
+ return createReactiveSubscription(async () => {
839
+ await touchOpsxProjectDeps(ctx.projectDir);
840
+ const schemaPath = join((await fetchOpsxSchemaResolution(ctx, input.name)).path, "schema.yaml");
841
+ const content = await reactiveReadFile(schemaPath);
842
+ if (!content) throw new Error(`schema.yaml not found at ${schemaPath}`);
843
+ return parseSchemaYaml(content);
844
+ });
845
+ }),
846
+ schemaFiles: publicProcedure.input(z.object({ name: z.string() })).query(async ({ ctx, input }) => {
847
+ await touchOpsxProjectDeps(ctx.projectDir);
848
+ return readEntriesUnderRoot((await fetchOpsxSchemaResolution(ctx, input.name)).path);
849
+ }),
850
+ subscribeSchemaFiles: publicProcedure.input(z.object({ name: z.string() })).subscription(({ ctx, input }) => {
851
+ return createReactiveSubscription(async () => {
852
+ await touchOpsxProjectDeps(ctx.projectDir);
853
+ return readEntriesUnderRoot((await fetchOpsxSchemaResolution(ctx, input.name)).path);
854
+ });
855
+ }),
856
+ schemaYaml: publicProcedure.input(z.object({ name: z.string() })).query(async ({ ctx, input }) => {
857
+ await touchOpsxProjectDeps(ctx.projectDir);
858
+ return reactiveReadFile(join((await fetchOpsxSchemaResolution(ctx, input.name)).path, "schema.yaml"));
859
+ }),
860
+ subscribeSchemaYaml: publicProcedure.input(z.object({ name: z.string() })).subscription(({ ctx, input }) => {
861
+ return createReactiveSubscription(async () => {
862
+ await touchOpsxProjectDeps(ctx.projectDir);
863
+ return reactiveReadFile(join((await fetchOpsxSchemaResolution(ctx, input.name)).path, "schema.yaml"));
864
+ });
865
+ }),
866
+ writeSchemaYaml: publicProcedure.input(z.object({
867
+ name: z.string(),
868
+ content: z.string()
869
+ })).mutation(async ({ ctx, input }) => {
870
+ await touchOpsxProjectDeps(ctx.projectDir);
871
+ const resolution = await fetchOpsxSchemaResolution(ctx, input.name);
872
+ ensureEditableSource(resolution.source, "schema.yaml");
873
+ const schemaPath = join(resolution.path, "schema.yaml");
874
+ await mkdir(dirname(schemaPath), { recursive: true });
875
+ await writeFile(schemaPath, input.content, "utf-8");
876
+ return { success: true };
877
+ }),
878
+ writeSchemaFile: publicProcedure.input(z.object({
879
+ schema: z.string(),
880
+ path: z.string(),
881
+ content: z.string()
882
+ })).mutation(async ({ ctx, input }) => {
883
+ await touchOpsxProjectDeps(ctx.projectDir);
884
+ const resolution = await fetchOpsxSchemaResolution(ctx, input.schema);
885
+ ensureEditableSource(resolution.source, "schema file");
886
+ if (!input.path.trim()) throw new Error("path is required");
887
+ const fullPath = resolveEntryPath(resolution.path, input.path);
888
+ await mkdir(dirname(fullPath), { recursive: true });
889
+ await writeFile(fullPath, input.content, "utf-8");
890
+ return { success: true };
891
+ }),
892
+ createSchemaFile: publicProcedure.input(z.object({
893
+ schema: z.string(),
894
+ path: z.string(),
895
+ content: z.string().optional()
896
+ })).mutation(async ({ ctx, input }) => {
897
+ await touchOpsxProjectDeps(ctx.projectDir);
898
+ const resolution = await fetchOpsxSchemaResolution(ctx, input.schema);
899
+ ensureEditableSource(resolution.source, "schema file");
900
+ if (!input.path.trim()) throw new Error("path is required");
901
+ const fullPath = resolveEntryPath(resolution.path, input.path);
902
+ await mkdir(dirname(fullPath), { recursive: true });
903
+ await writeFile(fullPath, input.content ?? "", "utf-8");
904
+ return { success: true };
905
+ }),
906
+ createSchemaDirectory: publicProcedure.input(z.object({
907
+ schema: z.string(),
908
+ path: z.string()
909
+ })).mutation(async ({ ctx, input }) => {
910
+ await touchOpsxProjectDeps(ctx.projectDir);
911
+ const resolution = await fetchOpsxSchemaResolution(ctx, input.schema);
912
+ ensureEditableSource(resolution.source, "schema directory");
913
+ if (!input.path.trim()) throw new Error("path is required");
914
+ await mkdir(resolveEntryPath(resolution.path, input.path), { recursive: true });
915
+ return { success: true };
916
+ }),
917
+ deleteSchemaEntry: publicProcedure.input(z.object({
918
+ schema: z.string(),
919
+ path: z.string()
920
+ })).mutation(async ({ ctx, input }) => {
921
+ await touchOpsxProjectDeps(ctx.projectDir);
922
+ const resolution = await fetchOpsxSchemaResolution(ctx, input.schema);
923
+ ensureEditableSource(resolution.source, "schema entry");
924
+ if (!input.path.trim()) throw new Error("path is required");
925
+ const fullPath = resolveEntryPath(resolution.path, input.path);
926
+ if (fullPath === resolve(resolution.path)) throw new Error("cannot delete schema root");
927
+ await rm(fullPath, {
928
+ recursive: true,
929
+ force: true
930
+ });
931
+ return { success: true };
932
+ }),
933
+ templateContent: publicProcedure.input(z.object({
934
+ schema: z.string(),
935
+ artifactId: z.string()
936
+ })).query(async ({ ctx, input }) => {
937
+ const info = (await fetchOpsxTemplates(ctx, input.schema))[input.artifactId];
938
+ if (!info) throw new Error(`Template not found for ${input.schema}:${input.artifactId}`);
939
+ return {
940
+ content: await reactiveReadFile(info.path),
941
+ path: info.path,
942
+ source: info.source
943
+ };
944
+ }),
945
+ subscribeTemplateContent: publicProcedure.input(z.object({
946
+ schema: z.string(),
947
+ artifactId: z.string()
948
+ })).subscription(({ ctx, input }) => {
949
+ return createReactiveSubscription(async () => {
950
+ const info = (await fetchOpsxTemplates(ctx, input.schema))[input.artifactId];
951
+ if (!info) throw new Error(`Template not found for ${input.schema}:${input.artifactId}`);
952
+ return {
953
+ content: await reactiveReadFile(info.path),
954
+ path: info.path,
955
+ source: info.source
956
+ };
957
+ });
958
+ }),
959
+ writeTemplateContent: publicProcedure.input(z.object({
960
+ schema: z.string(),
961
+ artifactId: z.string(),
962
+ content: z.string()
963
+ })).mutation(async ({ ctx, input }) => {
964
+ const info = (await fetchOpsxTemplates(ctx, input.schema))[input.artifactId];
965
+ if (!info) throw new Error(`Template not found for ${input.schema}:${input.artifactId}`);
966
+ ensureEditableSource(info.source, "template");
967
+ await mkdir(dirname(info.path), { recursive: true });
968
+ await writeFile(info.path, input.content, "utf-8");
969
+ return { success: true };
970
+ }),
971
+ deleteSchema: publicProcedure.input(z.object({ name: z.string() })).mutation(async ({ ctx, input }) => {
972
+ await touchOpsxProjectDeps(ctx.projectDir);
973
+ const resolution = await fetchOpsxSchemaResolution(ctx, input.name);
974
+ ensureEditableSource(resolution.source, "schema");
975
+ await rm(resolution.path, {
976
+ recursive: true,
977
+ force: true
978
+ });
979
+ return { success: true };
980
+ }),
981
+ projectConfig: publicProcedure.query(async ({ ctx }) => {
982
+ return reactiveReadFile(join(ctx.projectDir, "openspec", "config.yaml"));
983
+ }),
984
+ subscribeProjectConfig: publicProcedure.subscription(({ ctx }) => {
985
+ return createReactiveSubscription(async () => {
986
+ return reactiveReadFile(join(ctx.projectDir, "openspec", "config.yaml"));
987
+ });
988
+ }),
989
+ writeProjectConfig: publicProcedure.input(z.object({ content: z.string() })).mutation(async ({ ctx, input }) => {
990
+ const openspecDir = join(ctx.projectDir, "openspec");
991
+ await mkdir(openspecDir, { recursive: true });
992
+ await writeFile(join(openspecDir, "config.yaml"), input.content, "utf-8");
993
+ return { success: true };
994
+ }),
995
+ listChanges: publicProcedure.query(async ({ ctx }) => {
996
+ return reactiveReadDir(join(ctx.projectDir, "openspec", "changes"), {
997
+ directoriesOnly: true,
998
+ exclude: ["archive"],
999
+ includeHidden: false
1000
+ });
1001
+ }),
1002
+ subscribeChanges: publicProcedure.subscription(({ ctx }) => {
1003
+ return createReactiveSubscription(async () => {
1004
+ return reactiveReadDir(join(ctx.projectDir, "openspec", "changes"), {
1005
+ directoriesOnly: true,
1006
+ exclude: ["archive"],
1007
+ includeHidden: false
1008
+ });
1009
+ });
1010
+ }),
1011
+ changeMetadata: publicProcedure.input(z.object({ changeId: z.string() })).query(async ({ ctx, input }) => {
1012
+ return reactiveReadFile(join(ctx.projectDir, "openspec", "changes", input.changeId, ".openspec.yaml"));
1013
+ }),
1014
+ subscribeChangeMetadata: publicProcedure.input(z.object({ changeId: z.string() })).subscription(({ ctx, input }) => {
1015
+ return createReactiveSubscription(async () => {
1016
+ return reactiveReadFile(join(ctx.projectDir, "openspec", "changes", input.changeId, ".openspec.yaml"));
1017
+ });
1018
+ }),
1019
+ readArtifactOutput: publicProcedure.input(z.object({
1020
+ changeId: z.string(),
1021
+ outputPath: z.string()
1022
+ })).query(async ({ ctx, input }) => {
1023
+ return reactiveReadFile(join(ctx.projectDir, "openspec", "changes", input.changeId, input.outputPath));
1024
+ }),
1025
+ subscribeArtifactOutput: publicProcedure.input(z.object({
1026
+ changeId: z.string(),
1027
+ outputPath: z.string()
1028
+ })).subscription(({ ctx, input }) => {
1029
+ return createReactiveSubscription(async () => {
1030
+ return reactiveReadFile(join(ctx.projectDir, "openspec", "changes", input.changeId, input.outputPath));
1031
+ });
1032
+ }),
1033
+ readGlobArtifactFiles: publicProcedure.input(z.object({
1034
+ changeId: z.string(),
1035
+ outputPath: z.string()
1036
+ })).query(async ({ ctx, input }) => {
1037
+ return readGlobArtifactFiles(ctx.projectDir, input.changeId, input.outputPath);
1038
+ }),
1039
+ subscribeGlobArtifactFiles: publicProcedure.input(z.object({
1040
+ changeId: z.string(),
1041
+ outputPath: z.string()
1042
+ })).subscription(({ ctx, input }) => {
1043
+ return createReactiveSubscription(async () => {
1044
+ return readGlobArtifactFiles(ctx.projectDir, input.changeId, input.outputPath);
1045
+ });
1046
+ }),
1047
+ writeArtifactOutput: publicProcedure.input(z.object({
1048
+ changeId: z.string(),
1049
+ outputPath: z.string(),
1050
+ content: z.string()
1051
+ })).mutation(async ({ ctx, input }) => {
1052
+ await writeFile(join(ctx.projectDir, "openspec", "changes", input.changeId, input.outputPath), input.content, "utf-8");
1053
+ return { success: true };
1054
+ })
1055
+ });
1056
+ /**
1057
+ * KV router - in-memory reactive key-value store
1058
+ * No disk persistence — devices use IndexedDB for their own storage.
1059
+ */
1060
+ const kvRouter = router({
1061
+ get: publicProcedure.input(z.object({ key: z.string() })).query(({ input }) => {
1062
+ return reactiveKV.get(input.key) ?? null;
1063
+ }),
1064
+ set: publicProcedure.input(z.object({
1065
+ key: z.string(),
1066
+ value: z.unknown()
1067
+ })).mutation(({ input }) => {
1068
+ reactiveKV.set(input.key, input.value);
1069
+ return { success: true };
1070
+ }),
1071
+ delete: publicProcedure.input(z.object({ key: z.string() })).mutation(({ input }) => {
1072
+ reactiveKV.delete(input.key);
1073
+ return { success: true };
1074
+ }),
1075
+ subscribe: publicProcedure.input(z.object({ key: z.string() })).subscription(({ input }) => {
1076
+ return observable((emit) => {
1077
+ const current = reactiveKV.get(input.key);
1078
+ emit.next(current ?? null);
1079
+ const unsub = reactiveKV.onKey(input.key, (value) => {
1080
+ emit.next(value ?? null);
1081
+ });
1082
+ return () => {
1083
+ unsub();
1084
+ };
1085
+ });
1086
+ })
1087
+ });
1088
+ /**
504
1089
  * Main app router
505
1090
  */
506
1091
  const appRouter = router({
507
- dashboard: dashboardRouter,
508
1092
  spec: specRouter,
509
1093
  change: changeRouter,
510
1094
  archive: archiveRouter,
511
- project: projectRouter,
512
1095
  init: initRouter,
513
1096
  realtime: realtimeRouter,
514
1097
  config: configRouter,
515
- cli: cliRouter
1098
+ cli: cliRouter,
1099
+ opsx: opsxRouter,
1100
+ kv: kvRouter
516
1101
  });
517
1102
 
518
1103
  //#endregion
@@ -522,13 +1107,13 @@ const appRouter = router({
522
1107
  * Uses default binding (both IPv4 and IPv6) to detect conflicts.
523
1108
  */
524
1109
  function isPortAvailable(port) {
525
- return new Promise((resolve) => {
1110
+ return new Promise((resolve$1) => {
526
1111
  const server = createServer$1();
527
1112
  server.once("error", () => {
528
- resolve(false);
1113
+ resolve$1(false);
529
1114
  });
530
1115
  server.once("listening", () => {
531
- server.close(() => resolve(true));
1116
+ server.close(() => resolve$1(true));
532
1117
  });
533
1118
  server.listen(port);
534
1119
  });
@@ -550,6 +1135,340 @@ async function findAvailablePort(startPort, maxAttempts = 10) {
550
1135
  throw new Error(`No available port found in range ${startPort}-${startPort + maxAttempts - 1}`);
551
1136
  }
552
1137
 
1138
+ //#endregion
1139
+ //#region src/pty-manager.ts
1140
+ const DEFAULT_SCROLLBACK = 1e3;
1141
+ const DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024;
1142
+ function detectPtyPlatform() {
1143
+ if (process.platform === "win32") return "windows";
1144
+ if (process.platform === "darwin") return "macos";
1145
+ return "common";
1146
+ }
1147
+ var PtySession = class extends EventEmitter$1 {
1148
+ id;
1149
+ command;
1150
+ args;
1151
+ platform;
1152
+ createdAt;
1153
+ process;
1154
+ titleInterval = null;
1155
+ lastTitle = "";
1156
+ buffer = [];
1157
+ bufferByteLength = 0;
1158
+ maxBufferLines;
1159
+ maxBufferBytes;
1160
+ isExited = false;
1161
+ exitCode = null;
1162
+ constructor(id, opts) {
1163
+ super();
1164
+ this.id = id;
1165
+ this.createdAt = Date.now();
1166
+ const shell = opts.command ?? process.env.SHELL ?? "/bin/sh";
1167
+ const args = opts.command ? opts.args ?? [] : [];
1168
+ this.command = shell;
1169
+ this.args = args;
1170
+ this.platform = opts.platform;
1171
+ this.maxBufferLines = opts.scrollback ?? DEFAULT_SCROLLBACK;
1172
+ this.maxBufferBytes = opts.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
1173
+ this.process = pty.spawn(shell, args, {
1174
+ name: "xterm-256color",
1175
+ cols: opts.cols ?? 80,
1176
+ rows: opts.rows ?? 24,
1177
+ cwd: opts.cwd,
1178
+ env: {
1179
+ ...process.env,
1180
+ TERM: "xterm-256color"
1181
+ }
1182
+ });
1183
+ this.process.onData((data) => {
1184
+ this.appendBuffer(data);
1185
+ this.emit("data", data);
1186
+ });
1187
+ this.process.onExit(({ exitCode }) => {
1188
+ if (this.titleInterval) {
1189
+ clearInterval(this.titleInterval);
1190
+ this.titleInterval = null;
1191
+ }
1192
+ this.isExited = true;
1193
+ this.exitCode = exitCode;
1194
+ this.emit("exit", exitCode);
1195
+ });
1196
+ this.titleInterval = setInterval(() => {
1197
+ try {
1198
+ const title = this.process.process;
1199
+ if (title && title !== this.lastTitle) {
1200
+ this.lastTitle = title;
1201
+ this.emit("title", title);
1202
+ }
1203
+ } catch {}
1204
+ }, 1e3);
1205
+ }
1206
+ get title() {
1207
+ return this.lastTitle;
1208
+ }
1209
+ appendBuffer(data) {
1210
+ let chunk = data;
1211
+ if (chunk.length > this.maxBufferBytes) chunk = chunk.slice(-this.maxBufferBytes);
1212
+ this.buffer.push(chunk);
1213
+ this.bufferByteLength += chunk.length;
1214
+ while (this.bufferByteLength > this.maxBufferBytes && this.buffer.length > 0) {
1215
+ const removed = this.buffer.shift();
1216
+ this.bufferByteLength -= removed.length;
1217
+ }
1218
+ while (this.buffer.length > this.maxBufferLines) {
1219
+ const removed = this.buffer.shift();
1220
+ this.bufferByteLength -= removed.length;
1221
+ }
1222
+ }
1223
+ getBuffer() {
1224
+ return this.buffer.join("");
1225
+ }
1226
+ write(data) {
1227
+ if (!this.isExited) this.process.write(data);
1228
+ }
1229
+ resize(cols, rows) {
1230
+ if (!this.isExited) this.process.resize(cols, rows);
1231
+ }
1232
+ close() {
1233
+ if (this.titleInterval) {
1234
+ clearInterval(this.titleInterval);
1235
+ this.titleInterval = null;
1236
+ }
1237
+ try {
1238
+ this.process.kill();
1239
+ } catch {}
1240
+ this.removeAllListeners();
1241
+ }
1242
+ toInfo() {
1243
+ return {
1244
+ id: this.id,
1245
+ title: this.lastTitle,
1246
+ command: this.command,
1247
+ args: this.args,
1248
+ platform: this.platform,
1249
+ isExited: this.isExited,
1250
+ exitCode: this.exitCode,
1251
+ createdAt: this.createdAt
1252
+ };
1253
+ }
1254
+ };
1255
+ var PtyManager = class {
1256
+ sessions = /* @__PURE__ */ new Map();
1257
+ idCounter = 0;
1258
+ platform;
1259
+ constructor(defaultCwd) {
1260
+ this.defaultCwd = defaultCwd;
1261
+ this.platform = detectPtyPlatform();
1262
+ }
1263
+ create(opts) {
1264
+ const id = `pty-${++this.idCounter}`;
1265
+ const session = new PtySession(id, {
1266
+ cols: opts.cols,
1267
+ rows: opts.rows,
1268
+ command: opts.command,
1269
+ args: opts.args,
1270
+ cwd: this.defaultCwd,
1271
+ scrollback: opts.scrollback,
1272
+ maxBufferBytes: opts.maxBufferBytes,
1273
+ platform: this.platform
1274
+ });
1275
+ this.sessions.set(id, session);
1276
+ return session;
1277
+ }
1278
+ get(id) {
1279
+ return this.sessions.get(id);
1280
+ }
1281
+ list() {
1282
+ const result = [];
1283
+ for (const session of this.sessions.values()) result.push(session.toInfo());
1284
+ return result;
1285
+ }
1286
+ write(id, data) {
1287
+ this.sessions.get(id)?.write(data);
1288
+ }
1289
+ resize(id, cols, rows) {
1290
+ this.sessions.get(id)?.resize(cols, rows);
1291
+ }
1292
+ close(id) {
1293
+ const session = this.sessions.get(id);
1294
+ if (session) {
1295
+ session.close();
1296
+ this.sessions.delete(id);
1297
+ }
1298
+ }
1299
+ closeAll() {
1300
+ for (const session of this.sessions.values()) session.close();
1301
+ this.sessions.clear();
1302
+ }
1303
+ };
1304
+
1305
+ //#endregion
1306
+ //#region src/pty-websocket.ts
1307
+ function createPtyWebSocketHandler(ptyManager) {
1308
+ return (ws) => {
1309
+ const cleanups = /* @__PURE__ */ new Map();
1310
+ const send = (msg) => {
1311
+ if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
1312
+ };
1313
+ const sendError = (code, message, opts) => {
1314
+ send({
1315
+ type: "error",
1316
+ code,
1317
+ message,
1318
+ sessionId: opts?.sessionId
1319
+ });
1320
+ };
1321
+ const attachToSession = (session, opts) => {
1322
+ const sessionId = session.id;
1323
+ cleanups.get(sessionId)?.();
1324
+ if (opts?.cols && opts?.rows && !session.isExited) session.resize(opts.cols, opts.rows);
1325
+ const onData = (data) => {
1326
+ send({
1327
+ type: "output",
1328
+ sessionId,
1329
+ data
1330
+ });
1331
+ };
1332
+ const onExit = (exitCode) => {
1333
+ send({
1334
+ type: "exit",
1335
+ sessionId,
1336
+ exitCode
1337
+ });
1338
+ };
1339
+ const onTitle = (title) => {
1340
+ send({
1341
+ type: "title",
1342
+ sessionId,
1343
+ title
1344
+ });
1345
+ };
1346
+ session.on("data", onData);
1347
+ session.on("exit", onExit);
1348
+ session.on("title", onTitle);
1349
+ cleanups.set(sessionId, () => {
1350
+ session.removeListener("data", onData);
1351
+ session.removeListener("exit", onExit);
1352
+ session.removeListener("title", onTitle);
1353
+ cleanups.delete(sessionId);
1354
+ });
1355
+ };
1356
+ ws.on("message", (raw) => {
1357
+ let parsed;
1358
+ try {
1359
+ parsed = JSON.parse(String(raw));
1360
+ } catch {
1361
+ sendError("INVALID_JSON", "Invalid JSON payload");
1362
+ return;
1363
+ }
1364
+ const parsedMessage = PtyClientMessageSchema.safeParse(parsed);
1365
+ if (!parsedMessage.success) {
1366
+ const firstIssue = parsedMessage.error.issues[0]?.message;
1367
+ sendError("INVALID_MESSAGE", firstIssue ?? "Invalid PTY message");
1368
+ return;
1369
+ }
1370
+ const msg = parsedMessage.data;
1371
+ switch (msg.type) {
1372
+ case "create": {
1373
+ const session = ptyManager.create({
1374
+ cols: msg.cols,
1375
+ rows: msg.rows,
1376
+ command: msg.command,
1377
+ args: msg.args
1378
+ });
1379
+ send({
1380
+ type: "created",
1381
+ requestId: msg.requestId,
1382
+ sessionId: session.id,
1383
+ platform: session.platform
1384
+ });
1385
+ attachToSession(session);
1386
+ break;
1387
+ }
1388
+ case "attach": {
1389
+ const session = ptyManager.get(msg.sessionId);
1390
+ if (!session) {
1391
+ sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
1392
+ send({
1393
+ type: "exit",
1394
+ sessionId: msg.sessionId,
1395
+ exitCode: -1
1396
+ });
1397
+ break;
1398
+ }
1399
+ attachToSession(session, {
1400
+ cols: msg.cols,
1401
+ rows: msg.rows
1402
+ });
1403
+ const buffer = session.getBuffer();
1404
+ if (buffer) send({
1405
+ type: "buffer",
1406
+ sessionId: session.id,
1407
+ data: buffer
1408
+ });
1409
+ if (session.title) send({
1410
+ type: "title",
1411
+ sessionId: session.id,
1412
+ title: session.title
1413
+ });
1414
+ if (session.isExited) send({
1415
+ type: "exit",
1416
+ sessionId: session.id,
1417
+ exitCode: session.exitCode ?? -1
1418
+ });
1419
+ break;
1420
+ }
1421
+ case "list":
1422
+ send({
1423
+ type: "list",
1424
+ sessions: ptyManager.list().map((s) => ({
1425
+ id: s.id,
1426
+ title: s.title,
1427
+ command: s.command,
1428
+ args: s.args,
1429
+ platform: s.platform,
1430
+ isExited: s.isExited,
1431
+ exitCode: s.exitCode
1432
+ }))
1433
+ });
1434
+ break;
1435
+ case "input": {
1436
+ const session = ptyManager.get(msg.sessionId);
1437
+ if (!session) {
1438
+ sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
1439
+ break;
1440
+ }
1441
+ session.write(msg.data);
1442
+ break;
1443
+ }
1444
+ case "resize": {
1445
+ const session = ptyManager.get(msg.sessionId);
1446
+ if (!session) {
1447
+ sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
1448
+ break;
1449
+ }
1450
+ session.resize(msg.cols, msg.rows);
1451
+ break;
1452
+ }
1453
+ case "close": {
1454
+ const session = ptyManager.get(msg.sessionId);
1455
+ if (!session) {
1456
+ sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
1457
+ break;
1458
+ }
1459
+ cleanups.get(msg.sessionId)?.();
1460
+ ptyManager.close(session.id);
1461
+ break;
1462
+ }
1463
+ }
1464
+ });
1465
+ ws.on("close", () => {
1466
+ for (const cleanup of cleanups.values()) cleanup();
1467
+ cleanups.clear();
1468
+ });
1469
+ };
1470
+ }
1471
+
553
1472
  //#endregion
554
1473
  //#region src/server.ts
555
1474
  /**
@@ -571,6 +1490,7 @@ function createServer(config) {
571
1490
  const adapter = new OpenSpecAdapter(config.projectDir);
572
1491
  const configManager = new ConfigManager(config.projectDir);
573
1492
  const cliExecutor = new CliExecutor(configManager, config.projectDir);
1493
+ const kernel = config.kernel;
574
1494
  const watcher = config.enableWatcher !== false ? new OpenSpecWatcher(config.projectDir) : void 0;
575
1495
  const app = new Hono();
576
1496
  const corsOrigins = config.corsOrigins ?? ["http://localhost:5173", "http://localhost:3000"];
@@ -594,6 +1514,7 @@ function createServer(config) {
594
1514
  adapter,
595
1515
  configManager,
596
1516
  cliExecutor,
1517
+ kernel,
597
1518
  watcher,
598
1519
  projectDir: config.projectDir
599
1520
  })
@@ -603,6 +1524,7 @@ function createServer(config) {
603
1524
  adapter,
604
1525
  configManager,
605
1526
  cliExecutor,
1527
+ kernel,
606
1528
  watcher,
607
1529
  projectDir: config.projectDir
608
1530
  });
@@ -611,34 +1533,46 @@ function createServer(config) {
611
1533
  adapter,
612
1534
  configManager,
613
1535
  cliExecutor,
1536
+ kernel,
614
1537
  watcher,
615
1538
  createContext,
616
1539
  port: config.port ?? 3100
617
1540
  };
618
1541
  }
619
1542
  /**
620
- * Create WebSocket server for tRPC subscriptions
1543
+ * Create WebSocket server for tRPC subscriptions and PTY terminals
621
1544
  */
622
1545
  async function createWebSocketServer(server, httpServer, config) {
623
- await initWatcherPool(config.projectDir);
1546
+ if (!isWatcherPoolInitialized()) await initWatcherPool(config.projectDir);
624
1547
  const wss = new WebSocketServer({ noServer: true });
625
1548
  const handler = applyWSSHandler({
626
1549
  wss,
627
1550
  router: appRouter,
628
1551
  createContext: server.createContext
629
1552
  });
1553
+ const ptyManager = new PtyManager(config.projectDir);
1554
+ const ptyWss = new WebSocketServer({ noServer: true });
1555
+ const ptyHandler = createPtyWebSocketHandler(ptyManager);
1556
+ ptyWss.on("connection", ptyHandler);
630
1557
  httpServer.on("upgrade", (...args) => {
631
1558
  const [request, socket, head] = args;
632
- if (request.url?.startsWith("/trpc")) wss.handleUpgrade(request, socket, head, (ws) => {
1559
+ if (request.url?.startsWith("/ws/pty")) ptyWss.handleUpgrade(request, socket, head, (ws) => {
1560
+ ptyWss.emit("connection", ws, request);
1561
+ });
1562
+ else if (request.url?.startsWith("/trpc")) wss.handleUpgrade(request, socket, head, (ws) => {
633
1563
  wss.emit("connection", ws, request);
634
1564
  });
635
1565
  });
636
1566
  server.watcher?.start();
637
1567
  return {
638
1568
  wss,
1569
+ ptyWss,
1570
+ ptyManager,
639
1571
  handler,
640
1572
  close: () => {
641
1573
  handler.broadcastReconnectNotification();
1574
+ ptyManager.closeAll();
1575
+ ptyWss.close();
642
1576
  wss.close();
643
1577
  server.watcher?.stop();
644
1578
  }
@@ -655,9 +1589,13 @@ async function createWebSocketServer(server, httpServer, config) {
655
1589
  async function startServer(config, setupApp) {
656
1590
  const preferredPort = config.port ?? 3100;
657
1591
  const port = await findAvailablePort(preferredPort);
1592
+ const cliExecutor = new CliExecutor(new ConfigManager(config.projectDir), config.projectDir);
1593
+ const kernel = new OpsxKernel(config.projectDir, cliExecutor);
1594
+ await initWatcherPool(config.projectDir);
658
1595
  const server = createServer({
659
1596
  ...config,
660
- port
1597
+ port,
1598
+ kernel
661
1599
  });
662
1600
  if (setupApp) setupApp(server.app);
663
1601
  const httpServer = serve({
@@ -665,11 +1603,16 @@ async function startServer(config, setupApp) {
665
1603
  port
666
1604
  });
667
1605
  const wsServer = await createWebSocketServer(server, httpServer, { projectDir: config.projectDir });
1606
+ const url = `http://localhost:${port}`;
1607
+ kernel.warmup().catch((err) => {
1608
+ console.error("Kernel warmup failed:", err);
1609
+ });
668
1610
  return {
669
- url: `http://localhost:${port}`,
1611
+ url,
670
1612
  port,
671
1613
  preferredPort,
672
1614
  close: async () => {
1615
+ kernel.dispose();
673
1616
  wsServer.close();
674
1617
  httpServer.close();
675
1618
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openspecui/server",
3
- "version": "0.9.0",
3
+ "version": "1.0.2",
4
4
  "type": "module",
5
5
  "main": "dist/index.mjs",
6
6
  "exports": {
@@ -13,12 +13,14 @@
13
13
  ],
14
14
  "dependencies": {
15
15
  "@hono/node-server": "^1.14.1",
16
+ "@lydell/node-pty": "^1.1.0",
16
17
  "@trpc/server": "^11.0.0",
17
18
  "hono": "^4.7.3",
18
19
  "ws": "^8.18.0",
20
+ "yaml": "^2.8.0",
19
21
  "yargs": "^18.0.0",
20
22
  "zod": "^3.24.1",
21
- "@openspecui/core": "0.9.0"
23
+ "@openspecui/core": "1.0.2"
22
24
  },
23
25
  "devDependencies": {
24
26
  "@types/node": "^22.10.2",
@@ -31,8 +33,8 @@
31
33
  },
32
34
  "scripts": {
33
35
  "build": "tsdown src/index.ts --format esm --no-dts",
34
- "typecheck": "tsc --noEmit -p tsconfig.check.json",
35
- "dev": "tsx watch src/standalone.ts",
36
+ "typecheck": "tsc --noEmit",
37
+ "dev": "tsx watch --include '../core/dist/**' src/standalone.ts",
36
38
  "test": "vitest run",
37
39
  "test:watch": "vitest"
38
40
  }