@openspecui/server 0.9.0 → 1.0.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 +1012 -63
  2. package/package.json +5 -3
package/dist/index.mjs CHANGED
@@ -4,11 +4,17 @@ 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
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
9
+ import { dirname, join, matchesGlob, relative, resolve, sep } from "node:path";
8
10
  import { initTRPC } from "@trpc/server";
9
11
  import { observable } from "@trpc/server/observable";
10
12
  import { z } from "zod";
13
+ import YAML, { 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
 
13
19
  //#region src/reactive-subscription.ts
14
20
  /**
@@ -124,28 +130,291 @@ function createCliStreamObservable(startStream) {
124
130
  });
125
131
  }
126
132
 
133
+ //#endregion
134
+ //#region src/opsx-schema.ts
135
+ const SchemaYamlArtifactSchema = z.object({
136
+ id: z.string(),
137
+ generates: z.string(),
138
+ description: z.string().optional(),
139
+ template: z.string().optional(),
140
+ instruction: z.string().optional(),
141
+ requires: z.array(z.string()).optional()
142
+ });
143
+ const SchemaYamlSchema = z.object({
144
+ name: z.string(),
145
+ version: z.union([z.string(), z.number()]).optional(),
146
+ description: z.string().optional(),
147
+ artifacts: z.array(SchemaYamlArtifactSchema),
148
+ apply: z.object({
149
+ requires: z.array(z.string()).optional(),
150
+ tracks: z.string().optional(),
151
+ instruction: z.string().optional()
152
+ }).optional()
153
+ });
154
+ function parseSchemaYaml(content) {
155
+ const raw = parse(content);
156
+ const parsed = SchemaYamlSchema.safeParse(raw);
157
+ if (!parsed.success) throw new Error(`Invalid schema.yaml: ${parsed.error.message}`);
158
+ const { artifacts, apply, name, description, version } = parsed.data;
159
+ const detail = {
160
+ name,
161
+ description,
162
+ version,
163
+ artifacts: artifacts.map((artifact) => ({
164
+ id: artifact.id,
165
+ outputPath: artifact.generates,
166
+ description: artifact.description,
167
+ template: artifact.template,
168
+ instruction: artifact.instruction,
169
+ requires: artifact.requires ?? []
170
+ })),
171
+ applyRequires: apply?.requires ?? [],
172
+ applyTracks: apply?.tracks,
173
+ applyInstruction: apply?.instruction
174
+ };
175
+ const validated = SchemaDetailSchema.safeParse(detail);
176
+ if (!validated.success) throw new Error(`Invalid schema detail: ${validated.error.message}`);
177
+ return validated.data;
178
+ }
179
+
180
+ //#endregion
181
+ //#region src/reactive-kv.ts
182
+ /**
183
+ * In-memory reactive key-value store.
184
+ *
185
+ * - No disk persistence — devices own their own storage (e.g. IndexedDB).
186
+ * - Emits 'change' events when values are set or deleted.
187
+ * - Designed for cross-device sync scenarios where the server acts as a
188
+ * transient rendezvous point.
189
+ */
190
+ var ReactiveKV = class {
191
+ store = /* @__PURE__ */ new Map();
192
+ emitter = new EventEmitter();
193
+ constructor() {
194
+ this.emitter.setMaxListeners(200);
195
+ }
196
+ get(key) {
197
+ return this.store.get(key);
198
+ }
199
+ set(key, value) {
200
+ this.store.set(key, value);
201
+ this.emitter.emit("change", key);
202
+ this.emitter.emit(`change:${key}`, value);
203
+ }
204
+ delete(key) {
205
+ const existed = this.store.delete(key);
206
+ if (existed) {
207
+ this.emitter.emit("change", key);
208
+ this.emitter.emit(`change:${key}`, void 0);
209
+ }
210
+ return existed;
211
+ }
212
+ has(key) {
213
+ return this.store.has(key);
214
+ }
215
+ keys() {
216
+ return [...this.store.keys()];
217
+ }
218
+ /** Subscribe to changes for a specific key. Returns unsubscribe function. */
219
+ onKey(key, listener) {
220
+ const event = `change:${key}`;
221
+ this.emitter.on(event, listener);
222
+ return () => {
223
+ this.emitter.off(event, listener);
224
+ };
225
+ }
226
+ /** Subscribe to all changes. Returns unsubscribe function. */
227
+ onChange(listener) {
228
+ this.emitter.on("change", listener);
229
+ return () => {
230
+ this.emitter.off("change", listener);
231
+ };
232
+ }
233
+ };
234
+ /** Singleton instance shared across the server lifetime */
235
+ const reactiveKV = new ReactiveKV();
236
+
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({
@@ -501,18 +736,374 @@ const cliRouter = router({
501
736
  })
502
737
  });
503
738
  /**
739
+ * OPSX router - CLI-driven workflow data
740
+ */
741
+ const opsxRouter = router({
742
+ status: publicProcedure.input(z.object({
743
+ change: z.string().optional(),
744
+ schema: z.string().optional()
745
+ })).query(async ({ ctx, input }) => {
746
+ return fetchOpsxStatus(ctx, input);
747
+ }),
748
+ subscribeStatus: publicProcedure.input(z.object({
749
+ change: z.string().optional(),
750
+ schema: z.string().optional()
751
+ })).subscription(({ ctx, input }) => {
752
+ return createReactiveSubscription(() => fetchOpsxStatus(ctx, input));
753
+ }),
754
+ statusList: publicProcedure.query(async ({ ctx }) => {
755
+ return fetchOpsxStatusList(ctx);
756
+ }),
757
+ subscribeStatusList: publicProcedure.subscription(({ ctx }) => {
758
+ return createReactiveSubscription(() => fetchOpsxStatusList(ctx));
759
+ }),
760
+ instructions: publicProcedure.input(z.object({
761
+ change: z.string().optional(),
762
+ artifact: z.string(),
763
+ schema: z.string().optional()
764
+ })).query(async ({ ctx, input }) => {
765
+ return fetchOpsxInstructions(ctx, input);
766
+ }),
767
+ subscribeInstructions: publicProcedure.input(z.object({
768
+ change: z.string().optional(),
769
+ artifact: z.string(),
770
+ schema: z.string().optional()
771
+ })).subscription(({ ctx, input }) => {
772
+ return createReactiveSubscription(() => fetchOpsxInstructions(ctx, input));
773
+ }),
774
+ applyInstructions: publicProcedure.input(z.object({
775
+ change: z.string().optional(),
776
+ schema: z.string().optional()
777
+ })).query(async ({ ctx, input }) => {
778
+ return fetchOpsxApplyInstructions(ctx, input);
779
+ }),
780
+ subscribeApplyInstructions: publicProcedure.input(z.object({
781
+ change: z.string().optional(),
782
+ schema: z.string().optional()
783
+ })).subscription(({ ctx, input }) => {
784
+ return createReactiveSubscription(() => fetchOpsxApplyInstructions(ctx, input));
785
+ }),
786
+ schemas: publicProcedure.query(async ({ ctx }) => {
787
+ return fetchOpsxSchemas(ctx);
788
+ }),
789
+ subscribeSchemas: publicProcedure.subscription(({ ctx }) => {
790
+ return createReactiveSubscription(() => fetchOpsxSchemas(ctx));
791
+ }),
792
+ templates: publicProcedure.input(z.object({ schema: z.string().optional() }).optional()).query(async ({ ctx, input }) => {
793
+ return fetchOpsxTemplates(ctx, input?.schema);
794
+ }),
795
+ subscribeTemplates: publicProcedure.input(z.object({ schema: z.string().optional() }).optional()).subscription(({ ctx, input }) => {
796
+ return createReactiveSubscription(() => fetchOpsxTemplates(ctx, input?.schema));
797
+ }),
798
+ templateContents: publicProcedure.input(z.object({ schema: z.string().optional() }).optional()).query(async ({ ctx, input }) => {
799
+ return fetchOpsxTemplateContents(ctx, input?.schema);
800
+ }),
801
+ subscribeTemplateContents: publicProcedure.input(z.object({ schema: z.string().optional() }).optional()).subscription(({ ctx, input }) => {
802
+ return createReactiveSubscription(() => fetchOpsxTemplateContents(ctx, input?.schema));
803
+ }),
804
+ schemaResolution: publicProcedure.input(z.object({ name: z.string() })).query(async ({ ctx, input }) => {
805
+ return fetchOpsxSchemaResolution(ctx, input.name);
806
+ }),
807
+ subscribeSchemaResolution: publicProcedure.input(z.object({ name: z.string() })).subscription(({ ctx, input }) => {
808
+ return createReactiveSubscription(() => fetchOpsxSchemaResolution(ctx, input.name));
809
+ }),
810
+ schemaDetail: publicProcedure.input(z.object({ name: z.string() })).query(async ({ ctx, input }) => {
811
+ await touchOpsxProjectDeps(ctx.projectDir);
812
+ const schemaPath = join((await fetchOpsxSchemaResolution(ctx, input.name)).path, "schema.yaml");
813
+ const content = await reactiveReadFile(schemaPath);
814
+ if (!content) throw new Error(`schema.yaml not found at ${schemaPath}`);
815
+ return parseSchemaYaml(content);
816
+ }),
817
+ subscribeSchemaDetail: publicProcedure.input(z.object({ name: z.string() })).subscription(({ ctx, input }) => {
818
+ return createReactiveSubscription(async () => {
819
+ await touchOpsxProjectDeps(ctx.projectDir);
820
+ const schemaPath = join((await fetchOpsxSchemaResolution(ctx, input.name)).path, "schema.yaml");
821
+ const content = await reactiveReadFile(schemaPath);
822
+ if (!content) throw new Error(`schema.yaml not found at ${schemaPath}`);
823
+ return parseSchemaYaml(content);
824
+ });
825
+ }),
826
+ schemaFiles: publicProcedure.input(z.object({ name: z.string() })).query(async ({ ctx, input }) => {
827
+ await touchOpsxProjectDeps(ctx.projectDir);
828
+ return readEntriesUnderRoot((await fetchOpsxSchemaResolution(ctx, input.name)).path);
829
+ }),
830
+ subscribeSchemaFiles: publicProcedure.input(z.object({ name: z.string() })).subscription(({ ctx, input }) => {
831
+ return createReactiveSubscription(async () => {
832
+ await touchOpsxProjectDeps(ctx.projectDir);
833
+ return readEntriesUnderRoot((await fetchOpsxSchemaResolution(ctx, input.name)).path);
834
+ });
835
+ }),
836
+ schemaYaml: publicProcedure.input(z.object({ name: z.string() })).query(async ({ ctx, input }) => {
837
+ await touchOpsxProjectDeps(ctx.projectDir);
838
+ return reactiveReadFile(join((await fetchOpsxSchemaResolution(ctx, input.name)).path, "schema.yaml"));
839
+ }),
840
+ subscribeSchemaYaml: publicProcedure.input(z.object({ name: z.string() })).subscription(({ ctx, input }) => {
841
+ return createReactiveSubscription(async () => {
842
+ await touchOpsxProjectDeps(ctx.projectDir);
843
+ return reactiveReadFile(join((await fetchOpsxSchemaResolution(ctx, input.name)).path, "schema.yaml"));
844
+ });
845
+ }),
846
+ writeSchemaYaml: publicProcedure.input(z.object({
847
+ name: z.string(),
848
+ content: z.string()
849
+ })).mutation(async ({ ctx, input }) => {
850
+ await touchOpsxProjectDeps(ctx.projectDir);
851
+ const resolution = await fetchOpsxSchemaResolution(ctx, input.name);
852
+ ensureEditableSource(resolution.source, "schema.yaml");
853
+ const schemaPath = join(resolution.path, "schema.yaml");
854
+ await mkdir(dirname(schemaPath), { recursive: true });
855
+ await writeFile(schemaPath, input.content, "utf-8");
856
+ return { success: true };
857
+ }),
858
+ writeSchemaFile: publicProcedure.input(z.object({
859
+ schema: z.string(),
860
+ path: z.string(),
861
+ content: z.string()
862
+ })).mutation(async ({ ctx, input }) => {
863
+ await touchOpsxProjectDeps(ctx.projectDir);
864
+ const resolution = await fetchOpsxSchemaResolution(ctx, input.schema);
865
+ ensureEditableSource(resolution.source, "schema file");
866
+ if (!input.path.trim()) throw new Error("path is required");
867
+ const fullPath = resolveEntryPath(resolution.path, input.path);
868
+ await mkdir(dirname(fullPath), { recursive: true });
869
+ await writeFile(fullPath, input.content, "utf-8");
870
+ return { success: true };
871
+ }),
872
+ createSchemaFile: publicProcedure.input(z.object({
873
+ schema: z.string(),
874
+ path: z.string(),
875
+ content: z.string().optional()
876
+ })).mutation(async ({ ctx, input }) => {
877
+ await touchOpsxProjectDeps(ctx.projectDir);
878
+ const resolution = await fetchOpsxSchemaResolution(ctx, input.schema);
879
+ ensureEditableSource(resolution.source, "schema file");
880
+ if (!input.path.trim()) throw new Error("path is required");
881
+ const fullPath = resolveEntryPath(resolution.path, input.path);
882
+ await mkdir(dirname(fullPath), { recursive: true });
883
+ await writeFile(fullPath, input.content ?? "", "utf-8");
884
+ return { success: true };
885
+ }),
886
+ createSchemaDirectory: publicProcedure.input(z.object({
887
+ schema: z.string(),
888
+ path: z.string()
889
+ })).mutation(async ({ ctx, input }) => {
890
+ await touchOpsxProjectDeps(ctx.projectDir);
891
+ const resolution = await fetchOpsxSchemaResolution(ctx, input.schema);
892
+ ensureEditableSource(resolution.source, "schema directory");
893
+ if (!input.path.trim()) throw new Error("path is required");
894
+ await mkdir(resolveEntryPath(resolution.path, input.path), { recursive: true });
895
+ return { success: true };
896
+ }),
897
+ deleteSchemaEntry: publicProcedure.input(z.object({
898
+ schema: z.string(),
899
+ path: z.string()
900
+ })).mutation(async ({ ctx, input }) => {
901
+ await touchOpsxProjectDeps(ctx.projectDir);
902
+ const resolution = await fetchOpsxSchemaResolution(ctx, input.schema);
903
+ ensureEditableSource(resolution.source, "schema entry");
904
+ if (!input.path.trim()) throw new Error("path is required");
905
+ const fullPath = resolveEntryPath(resolution.path, input.path);
906
+ if (fullPath === resolve(resolution.path)) throw new Error("cannot delete schema root");
907
+ await rm(fullPath, {
908
+ recursive: true,
909
+ force: true
910
+ });
911
+ return { success: true };
912
+ }),
913
+ templateContent: publicProcedure.input(z.object({
914
+ schema: z.string(),
915
+ artifactId: z.string()
916
+ })).query(async ({ ctx, input }) => {
917
+ const info = (await fetchOpsxTemplates(ctx, input.schema))[input.artifactId];
918
+ if (!info) throw new Error(`Template not found for ${input.schema}:${input.artifactId}`);
919
+ return {
920
+ content: await reactiveReadFile(info.path),
921
+ path: info.path,
922
+ source: info.source
923
+ };
924
+ }),
925
+ subscribeTemplateContent: publicProcedure.input(z.object({
926
+ schema: z.string(),
927
+ artifactId: z.string()
928
+ })).subscription(({ ctx, input }) => {
929
+ return createReactiveSubscription(async () => {
930
+ const info = (await fetchOpsxTemplates(ctx, input.schema))[input.artifactId];
931
+ if (!info) throw new Error(`Template not found for ${input.schema}:${input.artifactId}`);
932
+ return {
933
+ content: await reactiveReadFile(info.path),
934
+ path: info.path,
935
+ source: info.source
936
+ };
937
+ });
938
+ }),
939
+ writeTemplateContent: publicProcedure.input(z.object({
940
+ schema: z.string(),
941
+ artifactId: z.string(),
942
+ content: z.string()
943
+ })).mutation(async ({ ctx, input }) => {
944
+ const info = (await fetchOpsxTemplates(ctx, input.schema))[input.artifactId];
945
+ if (!info) throw new Error(`Template not found for ${input.schema}:${input.artifactId}`);
946
+ ensureEditableSource(info.source, "template");
947
+ await mkdir(dirname(info.path), { recursive: true });
948
+ await writeFile(info.path, input.content, "utf-8");
949
+ return { success: true };
950
+ }),
951
+ deleteSchema: publicProcedure.input(z.object({ name: z.string() })).mutation(async ({ ctx, input }) => {
952
+ await touchOpsxProjectDeps(ctx.projectDir);
953
+ const resolution = await fetchOpsxSchemaResolution(ctx, input.name);
954
+ ensureEditableSource(resolution.source, "schema");
955
+ await rm(resolution.path, {
956
+ recursive: true,
957
+ force: true
958
+ });
959
+ return { success: true };
960
+ }),
961
+ projectConfig: publicProcedure.query(async ({ ctx }) => {
962
+ return reactiveReadFile(join(ctx.projectDir, "openspec", "config.yaml"));
963
+ }),
964
+ subscribeProjectConfig: publicProcedure.subscription(({ ctx }) => {
965
+ return createReactiveSubscription(async () => {
966
+ return reactiveReadFile(join(ctx.projectDir, "openspec", "config.yaml"));
967
+ });
968
+ }),
969
+ writeProjectConfig: publicProcedure.input(z.object({ content: z.string() })).mutation(async ({ ctx, input }) => {
970
+ const openspecDir = join(ctx.projectDir, "openspec");
971
+ await mkdir(openspecDir, { recursive: true });
972
+ await writeFile(join(openspecDir, "config.yaml"), input.content, "utf-8");
973
+ return { success: true };
974
+ }),
975
+ updateProjectConfigUi: publicProcedure.input(z.object({
976
+ "font-size": z.number().min(8).max(32).optional(),
977
+ "font-families": z.array(z.string()).optional(),
978
+ "cursor-blink": z.boolean().optional(),
979
+ "cursor-style": z.enum([
980
+ "block",
981
+ "underline",
982
+ "bar"
983
+ ]).optional(),
984
+ scrollback: z.number().min(0).max(1e5).optional()
985
+ })).mutation(async ({ ctx, input }) => {
986
+ const configPath = join(ctx.projectDir, "openspec", "config.yaml");
987
+ let doc;
988
+ try {
989
+ const content = await readFile(configPath, "utf-8");
990
+ doc = YAML.parseDocument(content);
991
+ } catch {
992
+ doc = new YAML.Document({});
993
+ }
994
+ if (!doc.has("ui")) doc.set("ui", doc.createNode({}));
995
+ const uiNode = doc.get("ui", true);
996
+ for (const [key, value] of Object.entries(input)) if (value !== void 0) uiNode.set(key, value);
997
+ await mkdir(join(ctx.projectDir, "openspec"), { recursive: true });
998
+ await writeFile(configPath, doc.toString(), "utf-8");
999
+ return { success: true };
1000
+ }),
1001
+ listChanges: publicProcedure.query(async ({ ctx }) => {
1002
+ return reactiveReadDir(join(ctx.projectDir, "openspec", "changes"), {
1003
+ directoriesOnly: true,
1004
+ exclude: ["archive"],
1005
+ includeHidden: false
1006
+ });
1007
+ }),
1008
+ subscribeChanges: publicProcedure.subscription(({ ctx }) => {
1009
+ return createReactiveSubscription(async () => {
1010
+ return reactiveReadDir(join(ctx.projectDir, "openspec", "changes"), {
1011
+ directoriesOnly: true,
1012
+ exclude: ["archive"],
1013
+ includeHidden: false
1014
+ });
1015
+ });
1016
+ }),
1017
+ changeMetadata: publicProcedure.input(z.object({ changeId: z.string() })).query(async ({ ctx, input }) => {
1018
+ return reactiveReadFile(join(ctx.projectDir, "openspec", "changes", input.changeId, ".openspec.yaml"));
1019
+ }),
1020
+ subscribeChangeMetadata: publicProcedure.input(z.object({ changeId: z.string() })).subscription(({ ctx, input }) => {
1021
+ return createReactiveSubscription(async () => {
1022
+ return reactiveReadFile(join(ctx.projectDir, "openspec", "changes", input.changeId, ".openspec.yaml"));
1023
+ });
1024
+ }),
1025
+ readArtifactOutput: publicProcedure.input(z.object({
1026
+ changeId: z.string(),
1027
+ outputPath: z.string()
1028
+ })).query(async ({ ctx, input }) => {
1029
+ return reactiveReadFile(join(ctx.projectDir, "openspec", "changes", input.changeId, input.outputPath));
1030
+ }),
1031
+ subscribeArtifactOutput: publicProcedure.input(z.object({
1032
+ changeId: z.string(),
1033
+ outputPath: z.string()
1034
+ })).subscription(({ ctx, input }) => {
1035
+ return createReactiveSubscription(async () => {
1036
+ return reactiveReadFile(join(ctx.projectDir, "openspec", "changes", input.changeId, input.outputPath));
1037
+ });
1038
+ }),
1039
+ readGlobArtifactFiles: publicProcedure.input(z.object({
1040
+ changeId: z.string(),
1041
+ outputPath: z.string()
1042
+ })).query(async ({ ctx, input }) => {
1043
+ return readGlobArtifactFiles(ctx.projectDir, input.changeId, input.outputPath);
1044
+ }),
1045
+ subscribeGlobArtifactFiles: publicProcedure.input(z.object({
1046
+ changeId: z.string(),
1047
+ outputPath: z.string()
1048
+ })).subscription(({ ctx, input }) => {
1049
+ return createReactiveSubscription(async () => {
1050
+ return readGlobArtifactFiles(ctx.projectDir, input.changeId, input.outputPath);
1051
+ });
1052
+ }),
1053
+ writeArtifactOutput: publicProcedure.input(z.object({
1054
+ changeId: z.string(),
1055
+ outputPath: z.string(),
1056
+ content: z.string()
1057
+ })).mutation(async ({ ctx, input }) => {
1058
+ await writeFile(join(ctx.projectDir, "openspec", "changes", input.changeId, input.outputPath), input.content, "utf-8");
1059
+ return { success: true };
1060
+ })
1061
+ });
1062
+ /**
1063
+ * KV router - in-memory reactive key-value store
1064
+ * No disk persistence — devices use IndexedDB for their own storage.
1065
+ */
1066
+ const kvRouter = router({
1067
+ get: publicProcedure.input(z.object({ key: z.string() })).query(({ input }) => {
1068
+ return reactiveKV.get(input.key) ?? null;
1069
+ }),
1070
+ set: publicProcedure.input(z.object({
1071
+ key: z.string(),
1072
+ value: z.unknown()
1073
+ })).mutation(({ input }) => {
1074
+ reactiveKV.set(input.key, input.value);
1075
+ return { success: true };
1076
+ }),
1077
+ delete: publicProcedure.input(z.object({ key: z.string() })).mutation(({ input }) => {
1078
+ reactiveKV.delete(input.key);
1079
+ return { success: true };
1080
+ }),
1081
+ subscribe: publicProcedure.input(z.object({ key: z.string() })).subscription(({ input }) => {
1082
+ return observable((emit) => {
1083
+ const current = reactiveKV.get(input.key);
1084
+ emit.next(current ?? null);
1085
+ const unsub = reactiveKV.onKey(input.key, (value) => {
1086
+ emit.next(value ?? null);
1087
+ });
1088
+ return () => {
1089
+ unsub();
1090
+ };
1091
+ });
1092
+ })
1093
+ });
1094
+ /**
504
1095
  * Main app router
505
1096
  */
506
1097
  const appRouter = router({
507
- dashboard: dashboardRouter,
508
1098
  spec: specRouter,
509
1099
  change: changeRouter,
510
1100
  archive: archiveRouter,
511
- project: projectRouter,
512
1101
  init: initRouter,
513
1102
  realtime: realtimeRouter,
514
1103
  config: configRouter,
515
- cli: cliRouter
1104
+ cli: cliRouter,
1105
+ opsx: opsxRouter,
1106
+ kv: kvRouter
516
1107
  });
517
1108
 
518
1109
  //#endregion
@@ -522,13 +1113,13 @@ const appRouter = router({
522
1113
  * Uses default binding (both IPv4 and IPv6) to detect conflicts.
523
1114
  */
524
1115
  function isPortAvailable(port) {
525
- return new Promise((resolve) => {
1116
+ return new Promise((resolve$1) => {
526
1117
  const server = createServer$1();
527
1118
  server.once("error", () => {
528
- resolve(false);
1119
+ resolve$1(false);
529
1120
  });
530
1121
  server.once("listening", () => {
531
- server.close(() => resolve(true));
1122
+ server.close(() => resolve$1(true));
532
1123
  });
533
1124
  server.listen(port);
534
1125
  });
@@ -550,6 +1141,340 @@ async function findAvailablePort(startPort, maxAttempts = 10) {
550
1141
  throw new Error(`No available port found in range ${startPort}-${startPort + maxAttempts - 1}`);
551
1142
  }
552
1143
 
1144
+ //#endregion
1145
+ //#region src/pty-manager.ts
1146
+ const DEFAULT_SCROLLBACK = 1e3;
1147
+ const DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024;
1148
+ function detectPtyPlatform() {
1149
+ if (process.platform === "win32") return "windows";
1150
+ if (process.platform === "darwin") return "macos";
1151
+ return "common";
1152
+ }
1153
+ var PtySession = class extends EventEmitter$1 {
1154
+ id;
1155
+ command;
1156
+ args;
1157
+ platform;
1158
+ createdAt;
1159
+ process;
1160
+ titleInterval = null;
1161
+ lastTitle = "";
1162
+ buffer = [];
1163
+ bufferByteLength = 0;
1164
+ maxBufferLines;
1165
+ maxBufferBytes;
1166
+ isExited = false;
1167
+ exitCode = null;
1168
+ constructor(id, opts) {
1169
+ super();
1170
+ this.id = id;
1171
+ this.createdAt = Date.now();
1172
+ const shell = opts.command ?? process.env.SHELL ?? "/bin/sh";
1173
+ const args = opts.command ? opts.args ?? [] : [];
1174
+ this.command = shell;
1175
+ this.args = args;
1176
+ this.platform = opts.platform;
1177
+ this.maxBufferLines = opts.scrollback ?? DEFAULT_SCROLLBACK;
1178
+ this.maxBufferBytes = opts.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
1179
+ this.process = pty.spawn(shell, args, {
1180
+ name: "xterm-256color",
1181
+ cols: opts.cols ?? 80,
1182
+ rows: opts.rows ?? 24,
1183
+ cwd: opts.cwd,
1184
+ env: {
1185
+ ...process.env,
1186
+ TERM: "xterm-256color"
1187
+ }
1188
+ });
1189
+ this.process.onData((data) => {
1190
+ this.appendBuffer(data);
1191
+ this.emit("data", data);
1192
+ });
1193
+ this.process.onExit(({ exitCode }) => {
1194
+ if (this.titleInterval) {
1195
+ clearInterval(this.titleInterval);
1196
+ this.titleInterval = null;
1197
+ }
1198
+ this.isExited = true;
1199
+ this.exitCode = exitCode;
1200
+ this.emit("exit", exitCode);
1201
+ });
1202
+ this.titleInterval = setInterval(() => {
1203
+ try {
1204
+ const title = this.process.process;
1205
+ if (title && title !== this.lastTitle) {
1206
+ this.lastTitle = title;
1207
+ this.emit("title", title);
1208
+ }
1209
+ } catch {}
1210
+ }, 1e3);
1211
+ }
1212
+ get title() {
1213
+ return this.lastTitle;
1214
+ }
1215
+ appendBuffer(data) {
1216
+ let chunk = data;
1217
+ if (chunk.length > this.maxBufferBytes) chunk = chunk.slice(-this.maxBufferBytes);
1218
+ this.buffer.push(chunk);
1219
+ this.bufferByteLength += chunk.length;
1220
+ while (this.bufferByteLength > this.maxBufferBytes && this.buffer.length > 0) {
1221
+ const removed = this.buffer.shift();
1222
+ this.bufferByteLength -= removed.length;
1223
+ }
1224
+ while (this.buffer.length > this.maxBufferLines) {
1225
+ const removed = this.buffer.shift();
1226
+ this.bufferByteLength -= removed.length;
1227
+ }
1228
+ }
1229
+ getBuffer() {
1230
+ return this.buffer.join("");
1231
+ }
1232
+ write(data) {
1233
+ if (!this.isExited) this.process.write(data);
1234
+ }
1235
+ resize(cols, rows) {
1236
+ if (!this.isExited) this.process.resize(cols, rows);
1237
+ }
1238
+ close() {
1239
+ if (this.titleInterval) {
1240
+ clearInterval(this.titleInterval);
1241
+ this.titleInterval = null;
1242
+ }
1243
+ try {
1244
+ this.process.kill();
1245
+ } catch {}
1246
+ this.removeAllListeners();
1247
+ }
1248
+ toInfo() {
1249
+ return {
1250
+ id: this.id,
1251
+ title: this.lastTitle,
1252
+ command: this.command,
1253
+ args: this.args,
1254
+ platform: this.platform,
1255
+ isExited: this.isExited,
1256
+ exitCode: this.exitCode,
1257
+ createdAt: this.createdAt
1258
+ };
1259
+ }
1260
+ };
1261
+ var PtyManager = class {
1262
+ sessions = /* @__PURE__ */ new Map();
1263
+ idCounter = 0;
1264
+ platform;
1265
+ constructor(defaultCwd) {
1266
+ this.defaultCwd = defaultCwd;
1267
+ this.platform = detectPtyPlatform();
1268
+ }
1269
+ create(opts) {
1270
+ const id = `pty-${++this.idCounter}`;
1271
+ const session = new PtySession(id, {
1272
+ cols: opts.cols,
1273
+ rows: opts.rows,
1274
+ command: opts.command,
1275
+ args: opts.args,
1276
+ cwd: this.defaultCwd,
1277
+ scrollback: opts.scrollback,
1278
+ maxBufferBytes: opts.maxBufferBytes,
1279
+ platform: this.platform
1280
+ });
1281
+ this.sessions.set(id, session);
1282
+ return session;
1283
+ }
1284
+ get(id) {
1285
+ return this.sessions.get(id);
1286
+ }
1287
+ list() {
1288
+ const result = [];
1289
+ for (const session of this.sessions.values()) result.push(session.toInfo());
1290
+ return result;
1291
+ }
1292
+ write(id, data) {
1293
+ this.sessions.get(id)?.write(data);
1294
+ }
1295
+ resize(id, cols, rows) {
1296
+ this.sessions.get(id)?.resize(cols, rows);
1297
+ }
1298
+ close(id) {
1299
+ const session = this.sessions.get(id);
1300
+ if (session) {
1301
+ session.close();
1302
+ this.sessions.delete(id);
1303
+ }
1304
+ }
1305
+ closeAll() {
1306
+ for (const session of this.sessions.values()) session.close();
1307
+ this.sessions.clear();
1308
+ }
1309
+ };
1310
+
1311
+ //#endregion
1312
+ //#region src/pty-websocket.ts
1313
+ function createPtyWebSocketHandler(ptyManager) {
1314
+ return (ws) => {
1315
+ const cleanups = /* @__PURE__ */ new Map();
1316
+ const send = (msg) => {
1317
+ if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
1318
+ };
1319
+ const sendError = (code, message, opts) => {
1320
+ send({
1321
+ type: "error",
1322
+ code,
1323
+ message,
1324
+ sessionId: opts?.sessionId
1325
+ });
1326
+ };
1327
+ const attachToSession = (session, opts) => {
1328
+ const sessionId = session.id;
1329
+ cleanups.get(sessionId)?.();
1330
+ if (opts?.cols && opts?.rows && !session.isExited) session.resize(opts.cols, opts.rows);
1331
+ const onData = (data) => {
1332
+ send({
1333
+ type: "output",
1334
+ sessionId,
1335
+ data
1336
+ });
1337
+ };
1338
+ const onExit = (exitCode) => {
1339
+ send({
1340
+ type: "exit",
1341
+ sessionId,
1342
+ exitCode
1343
+ });
1344
+ };
1345
+ const onTitle = (title) => {
1346
+ send({
1347
+ type: "title",
1348
+ sessionId,
1349
+ title
1350
+ });
1351
+ };
1352
+ session.on("data", onData);
1353
+ session.on("exit", onExit);
1354
+ session.on("title", onTitle);
1355
+ cleanups.set(sessionId, () => {
1356
+ session.removeListener("data", onData);
1357
+ session.removeListener("exit", onExit);
1358
+ session.removeListener("title", onTitle);
1359
+ cleanups.delete(sessionId);
1360
+ });
1361
+ };
1362
+ ws.on("message", (raw) => {
1363
+ let parsed;
1364
+ try {
1365
+ parsed = JSON.parse(String(raw));
1366
+ } catch {
1367
+ sendError("INVALID_JSON", "Invalid JSON payload");
1368
+ return;
1369
+ }
1370
+ const parsedMessage = PtyClientMessageSchema.safeParse(parsed);
1371
+ if (!parsedMessage.success) {
1372
+ const firstIssue = parsedMessage.error.issues[0]?.message;
1373
+ sendError("INVALID_MESSAGE", firstIssue ?? "Invalid PTY message");
1374
+ return;
1375
+ }
1376
+ const msg = parsedMessage.data;
1377
+ switch (msg.type) {
1378
+ case "create": {
1379
+ const session = ptyManager.create({
1380
+ cols: msg.cols,
1381
+ rows: msg.rows,
1382
+ command: msg.command,
1383
+ args: msg.args
1384
+ });
1385
+ send({
1386
+ type: "created",
1387
+ requestId: msg.requestId,
1388
+ sessionId: session.id,
1389
+ platform: session.platform
1390
+ });
1391
+ attachToSession(session);
1392
+ break;
1393
+ }
1394
+ case "attach": {
1395
+ const session = ptyManager.get(msg.sessionId);
1396
+ if (!session) {
1397
+ sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
1398
+ send({
1399
+ type: "exit",
1400
+ sessionId: msg.sessionId,
1401
+ exitCode: -1
1402
+ });
1403
+ break;
1404
+ }
1405
+ attachToSession(session, {
1406
+ cols: msg.cols,
1407
+ rows: msg.rows
1408
+ });
1409
+ const buffer = session.getBuffer();
1410
+ if (buffer) send({
1411
+ type: "buffer",
1412
+ sessionId: session.id,
1413
+ data: buffer
1414
+ });
1415
+ if (session.title) send({
1416
+ type: "title",
1417
+ sessionId: session.id,
1418
+ title: session.title
1419
+ });
1420
+ if (session.isExited) send({
1421
+ type: "exit",
1422
+ sessionId: session.id,
1423
+ exitCode: session.exitCode ?? -1
1424
+ });
1425
+ break;
1426
+ }
1427
+ case "list":
1428
+ send({
1429
+ type: "list",
1430
+ sessions: ptyManager.list().map((s) => ({
1431
+ id: s.id,
1432
+ title: s.title,
1433
+ command: s.command,
1434
+ args: s.args,
1435
+ platform: s.platform,
1436
+ isExited: s.isExited,
1437
+ exitCode: s.exitCode
1438
+ }))
1439
+ });
1440
+ break;
1441
+ case "input": {
1442
+ const session = ptyManager.get(msg.sessionId);
1443
+ if (!session) {
1444
+ sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
1445
+ break;
1446
+ }
1447
+ session.write(msg.data);
1448
+ break;
1449
+ }
1450
+ case "resize": {
1451
+ const session = ptyManager.get(msg.sessionId);
1452
+ if (!session) {
1453
+ sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
1454
+ break;
1455
+ }
1456
+ session.resize(msg.cols, msg.rows);
1457
+ break;
1458
+ }
1459
+ case "close": {
1460
+ const session = ptyManager.get(msg.sessionId);
1461
+ if (!session) {
1462
+ sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
1463
+ break;
1464
+ }
1465
+ cleanups.get(msg.sessionId)?.();
1466
+ ptyManager.close(session.id);
1467
+ break;
1468
+ }
1469
+ }
1470
+ });
1471
+ ws.on("close", () => {
1472
+ for (const cleanup of cleanups.values()) cleanup();
1473
+ cleanups.clear();
1474
+ });
1475
+ };
1476
+ }
1477
+
553
1478
  //#endregion
554
1479
  //#region src/server.ts
555
1480
  /**
@@ -571,6 +1496,7 @@ function createServer(config) {
571
1496
  const adapter = new OpenSpecAdapter(config.projectDir);
572
1497
  const configManager = new ConfigManager(config.projectDir);
573
1498
  const cliExecutor = new CliExecutor(configManager, config.projectDir);
1499
+ const kernel = config.kernel;
574
1500
  const watcher = config.enableWatcher !== false ? new OpenSpecWatcher(config.projectDir) : void 0;
575
1501
  const app = new Hono();
576
1502
  const corsOrigins = config.corsOrigins ?? ["http://localhost:5173", "http://localhost:3000"];
@@ -594,6 +1520,7 @@ function createServer(config) {
594
1520
  adapter,
595
1521
  configManager,
596
1522
  cliExecutor,
1523
+ kernel,
597
1524
  watcher,
598
1525
  projectDir: config.projectDir
599
1526
  })
@@ -603,6 +1530,7 @@ function createServer(config) {
603
1530
  adapter,
604
1531
  configManager,
605
1532
  cliExecutor,
1533
+ kernel,
606
1534
  watcher,
607
1535
  projectDir: config.projectDir
608
1536
  });
@@ -611,34 +1539,46 @@ function createServer(config) {
611
1539
  adapter,
612
1540
  configManager,
613
1541
  cliExecutor,
1542
+ kernel,
614
1543
  watcher,
615
1544
  createContext,
616
1545
  port: config.port ?? 3100
617
1546
  };
618
1547
  }
619
1548
  /**
620
- * Create WebSocket server for tRPC subscriptions
1549
+ * Create WebSocket server for tRPC subscriptions and PTY terminals
621
1550
  */
622
1551
  async function createWebSocketServer(server, httpServer, config) {
623
- await initWatcherPool(config.projectDir);
1552
+ if (!isWatcherPoolInitialized()) await initWatcherPool(config.projectDir);
624
1553
  const wss = new WebSocketServer({ noServer: true });
625
1554
  const handler = applyWSSHandler({
626
1555
  wss,
627
1556
  router: appRouter,
628
1557
  createContext: server.createContext
629
1558
  });
1559
+ const ptyManager = new PtyManager(config.projectDir);
1560
+ const ptyWss = new WebSocketServer({ noServer: true });
1561
+ const ptyHandler = createPtyWebSocketHandler(ptyManager);
1562
+ ptyWss.on("connection", ptyHandler);
630
1563
  httpServer.on("upgrade", (...args) => {
631
1564
  const [request, socket, head] = args;
632
- if (request.url?.startsWith("/trpc")) wss.handleUpgrade(request, socket, head, (ws) => {
1565
+ if (request.url?.startsWith("/ws/pty")) ptyWss.handleUpgrade(request, socket, head, (ws) => {
1566
+ ptyWss.emit("connection", ws, request);
1567
+ });
1568
+ else if (request.url?.startsWith("/trpc")) wss.handleUpgrade(request, socket, head, (ws) => {
633
1569
  wss.emit("connection", ws, request);
634
1570
  });
635
1571
  });
636
1572
  server.watcher?.start();
637
1573
  return {
638
1574
  wss,
1575
+ ptyWss,
1576
+ ptyManager,
639
1577
  handler,
640
1578
  close: () => {
641
1579
  handler.broadcastReconnectNotification();
1580
+ ptyManager.closeAll();
1581
+ ptyWss.close();
642
1582
  wss.close();
643
1583
  server.watcher?.stop();
644
1584
  }
@@ -655,9 +1595,13 @@ async function createWebSocketServer(server, httpServer, config) {
655
1595
  async function startServer(config, setupApp) {
656
1596
  const preferredPort = config.port ?? 3100;
657
1597
  const port = await findAvailablePort(preferredPort);
1598
+ const cliExecutor = new CliExecutor(new ConfigManager(config.projectDir), config.projectDir);
1599
+ const kernel = new OpsxKernel(config.projectDir, cliExecutor);
1600
+ await initWatcherPool(config.projectDir);
658
1601
  const server = createServer({
659
1602
  ...config,
660
- port
1603
+ port,
1604
+ kernel
661
1605
  });
662
1606
  if (setupApp) setupApp(server.app);
663
1607
  const httpServer = serve({
@@ -665,11 +1609,16 @@ async function startServer(config, setupApp) {
665
1609
  port
666
1610
  });
667
1611
  const wsServer = await createWebSocketServer(server, httpServer, { projectDir: config.projectDir });
1612
+ const url = `http://localhost:${port}`;
1613
+ kernel.warmup().catch((err) => {
1614
+ console.error("Kernel warmup failed:", err);
1615
+ });
668
1616
  return {
669
- url: `http://localhost:${port}`,
1617
+ url,
670
1618
  port,
671
1619
  preferredPort,
672
1620
  close: async () => {
1621
+ kernel.dispose();
673
1622
  wsServer.close();
674
1623
  httpServer.close();
675
1624
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openspecui/server",
3
- "version": "0.9.0",
3
+ "version": "1.0.0",
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.0"
22
24
  },
23
25
  "devDependencies": {
24
26
  "@types/node": "^22.10.2",
@@ -31,7 +33,7 @@
31
33
  },
32
34
  "scripts": {
33
35
  "build": "tsdown src/index.ts --format esm --no-dts",
34
- "typecheck": "tsc --noEmit -p tsconfig.check.json",
36
+ "typecheck": "tsc --noEmit",
35
37
  "dev": "tsx watch src/standalone.ts",
36
38
  "test": "vitest run",
37
39
  "test:watch": "vitest"