@openspecui/server 1.0.4 → 1.1.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 +634 -652
  2. package/package.json +4 -3
package/dist/index.mjs CHANGED
@@ -1,21 +1,413 @@
1
1
  import { serve } from "@hono/node-server";
2
- import { Hono } from "hono";
3
- import { cors } from "hono/cors";
2
+ import { CliExecutor, ConfigManager, OpenSpecAdapter, OpenSpecWatcher, OpsxKernel, PtyClientMessageSchema, ReactiveContext, getAllTools, getAvailableTools, getConfiguredTools, getDefaultCliCommandString, initWatcherPool, isWatcherPoolInitialized, sniffGlobalCli } from "@openspecui/core";
4
3
  import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
5
4
  import { applyWSSHandler } from "@trpc/server/adapters/ws";
5
+ import { Hono } from "hono";
6
+ import { cors } from "hono/cors";
6
7
  import { WebSocketServer } from "ws";
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 { createServer as createServer$1 } from "node:net";
9
+ import * as pty from "@lydell/node-pty";
10
+ import { EventEmitter } from "events";
11
+ import { SearchQuerySchema } from "@openspecui/search";
8
12
  import { initTRPC } from "@trpc/server";
9
13
  import { observable } from "@trpc/server/observable";
10
14
  import { mkdir, rm, writeFile } from "node:fs/promises";
11
- import { dirname, join, matchesGlob, relative, resolve, sep } from "node:path";
15
+ import { dirname, join, resolve, sep } from "node:path";
12
16
  import { z } from "zod";
13
- import { parse } from "yaml";
14
- import { EventEmitter } from "node:events";
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";
17
+ import { EventEmitter as EventEmitter$1 } from "node:events";
18
+ import { NodeWorkerSearchProvider } from "@openspecui/search/node";
19
+
20
+ //#region src/port-utils.ts
21
+ /**
22
+ * Check if a port is available by trying to listen on it.
23
+ * Uses default binding (both IPv4 and IPv6) to detect conflicts.
24
+ */
25
+ function isPortAvailable(port) {
26
+ return new Promise((resolve$1) => {
27
+ const server = createServer$1();
28
+ server.once("error", () => {
29
+ resolve$1(false);
30
+ });
31
+ server.once("listening", () => {
32
+ server.close(() => resolve$1(true));
33
+ });
34
+ server.listen(port);
35
+ });
36
+ }
37
+ /**
38
+ * Find an available port starting from the given port.
39
+ * Will try up to maxAttempts ports sequentially.
40
+ *
41
+ * @param startPort - The preferred port to start checking from
42
+ * @param maxAttempts - Maximum number of ports to try (default: 10)
43
+ * @returns The first available port found
44
+ * @throws Error if no available port is found in the range
45
+ */
46
+ async function findAvailablePort(startPort, maxAttempts = 10) {
47
+ for (let i = 0; i < maxAttempts; i++) {
48
+ const port = startPort + i;
49
+ if (await isPortAvailable(port)) return port;
50
+ }
51
+ throw new Error(`No available port found in range ${startPort}-${startPort + maxAttempts - 1}`);
52
+ }
53
+
54
+ //#endregion
55
+ //#region src/pty-manager.ts
56
+ const DEFAULT_SCROLLBACK = 1e3;
57
+ const DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024;
58
+ function detectPtyPlatform() {
59
+ if (process.platform === "win32") return "windows";
60
+ if (process.platform === "darwin") return "macos";
61
+ return "common";
62
+ }
63
+ function resolveDefaultShell(platform, env) {
64
+ if (platform === "windows") return env.ComSpec?.trim() || "cmd.exe";
65
+ return env.SHELL?.trim() || "/bin/sh";
66
+ }
67
+ function resolvePtyCommand(opts) {
68
+ const command = opts.command?.trim();
69
+ if (command) return {
70
+ command,
71
+ args: opts.args ?? []
72
+ };
73
+ return {
74
+ command: resolveDefaultShell(opts.platform, opts.env),
75
+ args: []
76
+ };
77
+ }
78
+ var PtySession = class extends EventEmitter {
79
+ id;
80
+ command;
81
+ args;
82
+ platform;
83
+ createdAt;
84
+ process;
85
+ titleInterval = null;
86
+ lastTitle = "";
87
+ buffer = [];
88
+ bufferByteLength = 0;
89
+ maxBufferLines;
90
+ maxBufferBytes;
91
+ isExited = false;
92
+ exitCode = null;
93
+ constructor(id, opts) {
94
+ super();
95
+ this.id = id;
96
+ this.createdAt = Date.now();
97
+ const resolvedCommand = resolvePtyCommand({
98
+ platform: opts.platform,
99
+ command: opts.command,
100
+ args: opts.args,
101
+ env: process.env
102
+ });
103
+ this.command = resolvedCommand.command;
104
+ this.args = resolvedCommand.args;
105
+ this.platform = opts.platform;
106
+ this.maxBufferLines = opts.scrollback ?? DEFAULT_SCROLLBACK;
107
+ this.maxBufferBytes = opts.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
108
+ this.process = pty.spawn(this.command, this.args, {
109
+ name: "xterm-256color",
110
+ cols: opts.cols ?? 80,
111
+ rows: opts.rows ?? 24,
112
+ cwd: opts.cwd,
113
+ env: {
114
+ ...process.env,
115
+ TERM: "xterm-256color"
116
+ }
117
+ });
118
+ this.process.onData((data) => {
119
+ this.appendBuffer(data);
120
+ this.emit("data", data);
121
+ });
122
+ this.process.onExit(({ exitCode }) => {
123
+ if (this.titleInterval) {
124
+ clearInterval(this.titleInterval);
125
+ this.titleInterval = null;
126
+ }
127
+ this.isExited = true;
128
+ this.exitCode = exitCode;
129
+ this.emit("exit", exitCode);
130
+ });
131
+ this.titleInterval = setInterval(() => {
132
+ try {
133
+ const title = this.process.process;
134
+ if (title && title !== this.lastTitle) {
135
+ this.lastTitle = title;
136
+ this.emit("title", title);
137
+ }
138
+ } catch {}
139
+ }, 1e3);
140
+ }
141
+ get title() {
142
+ return this.lastTitle;
143
+ }
144
+ appendBuffer(data) {
145
+ let chunk = data;
146
+ if (chunk.length > this.maxBufferBytes) chunk = chunk.slice(-this.maxBufferBytes);
147
+ this.buffer.push(chunk);
148
+ this.bufferByteLength += chunk.length;
149
+ while (this.bufferByteLength > this.maxBufferBytes && this.buffer.length > 0) {
150
+ const removed = this.buffer.shift();
151
+ this.bufferByteLength -= removed.length;
152
+ }
153
+ while (this.buffer.length > this.maxBufferLines) {
154
+ const removed = this.buffer.shift();
155
+ this.bufferByteLength -= removed.length;
156
+ }
157
+ }
158
+ getBuffer() {
159
+ return this.buffer.join("");
160
+ }
161
+ write(data) {
162
+ if (!this.isExited) this.process.write(data);
163
+ }
164
+ resize(cols, rows) {
165
+ if (!this.isExited) this.process.resize(cols, rows);
166
+ }
167
+ close() {
168
+ if (this.titleInterval) {
169
+ clearInterval(this.titleInterval);
170
+ this.titleInterval = null;
171
+ }
172
+ try {
173
+ this.process.kill();
174
+ } catch {}
175
+ this.removeAllListeners();
176
+ }
177
+ toInfo() {
178
+ return {
179
+ id: this.id,
180
+ title: this.lastTitle,
181
+ command: this.command,
182
+ args: this.args,
183
+ platform: this.platform,
184
+ isExited: this.isExited,
185
+ exitCode: this.exitCode,
186
+ createdAt: this.createdAt
187
+ };
188
+ }
189
+ };
190
+ var PtyManager = class {
191
+ sessions = /* @__PURE__ */ new Map();
192
+ idCounter = 0;
193
+ platform;
194
+ constructor(defaultCwd) {
195
+ this.defaultCwd = defaultCwd;
196
+ this.platform = detectPtyPlatform();
197
+ }
198
+ create(opts) {
199
+ const id = `pty-${++this.idCounter}`;
200
+ const session = new PtySession(id, {
201
+ cols: opts.cols,
202
+ rows: opts.rows,
203
+ command: opts.command,
204
+ args: opts.args,
205
+ cwd: this.defaultCwd,
206
+ scrollback: opts.scrollback,
207
+ maxBufferBytes: opts.maxBufferBytes,
208
+ platform: this.platform
209
+ });
210
+ this.sessions.set(id, session);
211
+ return session;
212
+ }
213
+ get(id) {
214
+ return this.sessions.get(id);
215
+ }
216
+ list() {
217
+ const result = [];
218
+ for (const session of this.sessions.values()) result.push(session.toInfo());
219
+ return result;
220
+ }
221
+ write(id, data) {
222
+ this.sessions.get(id)?.write(data);
223
+ }
224
+ resize(id, cols, rows) {
225
+ this.sessions.get(id)?.resize(cols, rows);
226
+ }
227
+ close(id) {
228
+ const session = this.sessions.get(id);
229
+ if (session) {
230
+ session.close();
231
+ this.sessions.delete(id);
232
+ }
233
+ }
234
+ closeAll() {
235
+ for (const session of this.sessions.values()) session.close();
236
+ this.sessions.clear();
237
+ }
238
+ };
239
+
240
+ //#endregion
241
+ //#region src/pty-websocket.ts
242
+ function createPtyWebSocketHandler(ptyManager) {
243
+ return (ws) => {
244
+ const cleanups = /* @__PURE__ */ new Map();
245
+ const send = (msg) => {
246
+ if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
247
+ };
248
+ const sendError = (code, message, opts) => {
249
+ send({
250
+ type: "error",
251
+ code,
252
+ message,
253
+ sessionId: opts?.sessionId
254
+ });
255
+ };
256
+ const attachToSession = (session, opts) => {
257
+ const sessionId = session.id;
258
+ cleanups.get(sessionId)?.();
259
+ if (opts?.cols && opts?.rows && !session.isExited) session.resize(opts.cols, opts.rows);
260
+ const onData = (data) => {
261
+ send({
262
+ type: "output",
263
+ sessionId,
264
+ data
265
+ });
266
+ };
267
+ const onExit = (exitCode) => {
268
+ send({
269
+ type: "exit",
270
+ sessionId,
271
+ exitCode
272
+ });
273
+ };
274
+ const onTitle = (title) => {
275
+ send({
276
+ type: "title",
277
+ sessionId,
278
+ title
279
+ });
280
+ };
281
+ session.on("data", onData);
282
+ session.on("exit", onExit);
283
+ session.on("title", onTitle);
284
+ cleanups.set(sessionId, () => {
285
+ session.removeListener("data", onData);
286
+ session.removeListener("exit", onExit);
287
+ session.removeListener("title", onTitle);
288
+ cleanups.delete(sessionId);
289
+ });
290
+ };
291
+ ws.on("message", (raw) => {
292
+ let parsed;
293
+ try {
294
+ parsed = JSON.parse(String(raw));
295
+ } catch {
296
+ sendError("INVALID_JSON", "Invalid JSON payload");
297
+ return;
298
+ }
299
+ const parsedMessage = PtyClientMessageSchema.safeParse(parsed);
300
+ if (!parsedMessage.success) {
301
+ const firstIssue = parsedMessage.error.issues[0]?.message;
302
+ sendError("INVALID_MESSAGE", firstIssue ?? "Invalid PTY message");
303
+ return;
304
+ }
305
+ const msg = parsedMessage.data;
306
+ switch (msg.type) {
307
+ case "create":
308
+ try {
309
+ const session = ptyManager.create({
310
+ cols: msg.cols,
311
+ rows: msg.rows,
312
+ command: msg.command,
313
+ args: msg.args
314
+ });
315
+ send({
316
+ type: "created",
317
+ requestId: msg.requestId,
318
+ sessionId: session.id,
319
+ platform: session.platform
320
+ });
321
+ attachToSession(session);
322
+ } catch (err) {
323
+ sendError("PTY_CREATE_FAILED", err instanceof Error ? err.message : String(err), { sessionId: msg.requestId });
324
+ }
325
+ break;
326
+ case "attach": {
327
+ const session = ptyManager.get(msg.sessionId);
328
+ if (!session) {
329
+ sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
330
+ send({
331
+ type: "exit",
332
+ sessionId: msg.sessionId,
333
+ exitCode: -1
334
+ });
335
+ break;
336
+ }
337
+ attachToSession(session, {
338
+ cols: msg.cols,
339
+ rows: msg.rows
340
+ });
341
+ const buffer = session.getBuffer();
342
+ if (buffer) send({
343
+ type: "buffer",
344
+ sessionId: session.id,
345
+ data: buffer
346
+ });
347
+ if (session.title) send({
348
+ type: "title",
349
+ sessionId: session.id,
350
+ title: session.title
351
+ });
352
+ if (session.isExited) send({
353
+ type: "exit",
354
+ sessionId: session.id,
355
+ exitCode: session.exitCode ?? -1
356
+ });
357
+ break;
358
+ }
359
+ case "list":
360
+ send({
361
+ type: "list",
362
+ sessions: ptyManager.list().map((s) => ({
363
+ id: s.id,
364
+ title: s.title,
365
+ command: s.command,
366
+ args: s.args,
367
+ platform: s.platform,
368
+ isExited: s.isExited,
369
+ exitCode: s.exitCode
370
+ }))
371
+ });
372
+ break;
373
+ case "input": {
374
+ const session = ptyManager.get(msg.sessionId);
375
+ if (!session) {
376
+ sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
377
+ break;
378
+ }
379
+ session.write(msg.data);
380
+ break;
381
+ }
382
+ case "resize": {
383
+ const session = ptyManager.get(msg.sessionId);
384
+ if (!session) {
385
+ sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
386
+ break;
387
+ }
388
+ session.resize(msg.cols, msg.rows);
389
+ break;
390
+ }
391
+ case "close": {
392
+ const session = ptyManager.get(msg.sessionId);
393
+ if (!session) {
394
+ sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
395
+ break;
396
+ }
397
+ cleanups.get(msg.sessionId)?.();
398
+ ptyManager.close(session.id);
399
+ break;
400
+ }
401
+ }
402
+ });
403
+ ws.on("close", () => {
404
+ for (const cleanup of cleanups.values()) cleanup();
405
+ cleanups.clear();
406
+ });
407
+ };
408
+ }
18
409
 
410
+ //#endregion
19
411
  //#region src/cli-stream-observable.ts
20
412
  /**
21
413
  * 创建安全的 CLI 流式 observable
@@ -72,53 +464,6 @@ function createCliStreamObservable(startStream) {
72
464
  });
73
465
  }
74
466
 
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
467
  //#endregion
123
468
  //#region src/reactive-kv.ts
124
469
  /**
@@ -131,7 +476,7 @@ function parseSchemaYaml(content) {
131
476
  */
132
477
  var ReactiveKV = class {
133
478
  store = /* @__PURE__ */ new Map();
134
- emitter = new EventEmitter();
479
+ emitter = new EventEmitter$1();
135
480
  constructor() {
136
481
  this.emitter.setMaxListeners(200);
137
482
  }
@@ -246,20 +591,6 @@ function requireChangeId(changeId) {
246
591
  function ensureEditableSource(source, label) {
247
592
  if (source === "package") throw new Error(`${label} is read-only (package source)`);
248
593
  }
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
594
  function resolveEntryPath(root, entryPath) {
264
595
  const normalizedRoot = resolve(root);
265
596
  const resolvedPath = resolve(normalizedRoot, entryPath);
@@ -267,153 +598,62 @@ function resolveEntryPath(root, entryPath) {
267
598
  if (resolvedPath !== normalizedRoot && !resolvedPath.startsWith(rootPrefix)) throw new Error("Invalid path: outside schema root");
268
599
  return resolvedPath;
269
600
  }
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
601
  async function fetchOpsxStatus(ctx, input) {
332
602
  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;
603
+ await ctx.kernel.waitForWarmup();
604
+ await ctx.kernel.ensureStatus(changeId, input.schema);
605
+ return ctx.kernel.getStatus(changeId, input.schema);
348
606
  }
349
607
  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 })));
608
+ await ctx.kernel.waitForWarmup();
609
+ await ctx.kernel.ensureStatusList();
610
+ return ctx.kernel.getStatusList();
356
611
  }
357
612
  async function fetchOpsxInstructions(ctx, input) {
358
613
  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");
614
+ await ctx.kernel.waitForWarmup();
615
+ await ctx.kernel.ensureInstructions(changeId, input.artifact, input.schema);
616
+ return ctx.kernel.getInstructions(changeId, input.artifact, input.schema);
372
617
  }
373
618
  async function fetchOpsxApplyInstructions(ctx, input) {
374
619
  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");
620
+ await ctx.kernel.waitForWarmup();
621
+ await ctx.kernel.ensureApplyInstructions(changeId, input.schema);
622
+ return ctx.kernel.getApplyInstructions(changeId, input.schema);
388
623
  }
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");
624
+ async function fetchOpsxConfigBundle(ctx) {
625
+ await ctx.kernel.ensureSchemas();
626
+ const schemas = ctx.kernel.getSchemas();
627
+ for (const schema of schemas) {
628
+ ctx.kernel.ensureSchemaDetail(schema.name).catch(() => {});
629
+ ctx.kernel.ensureSchemaResolution(schema.name).catch(() => {});
630
+ }
631
+ const schemaDetails = {};
632
+ const schemaResolutions = {};
633
+ for (const schema of schemas) {
634
+ schemaDetails[schema.name] = ctx.kernel.peekSchemaDetail(schema.name);
635
+ schemaResolutions[schema.name] = ctx.kernel.peekSchemaResolution(schema.name);
636
+ }
637
+ return {
638
+ schemas,
639
+ schemaDetails,
640
+ schemaResolutions
641
+ };
394
642
  }
395
643
  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");
644
+ await ctx.kernel.waitForWarmup();
645
+ await ctx.kernel.ensureSchemaResolution(name);
646
+ return ctx.kernel.getSchemaResolution(name);
400
647
  }
401
648
  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");
649
+ await ctx.kernel.waitForWarmup();
650
+ await ctx.kernel.ensureTemplates(schema);
651
+ return ctx.kernel.getTemplates(schema);
406
652
  }
407
653
  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);
654
+ await ctx.kernel.waitForWarmup();
655
+ await ctx.kernel.ensureTemplateContents(schema);
656
+ return ctx.kernel.getTemplateContents(schema);
417
657
  }
418
658
  /**
419
659
  * Spec router - spec CRUD operations
@@ -803,11 +1043,11 @@ const opsxRouter = router({
803
1043
  })).subscription(({ ctx, input }) => {
804
1044
  return createReactiveSubscription(() => fetchOpsxApplyInstructions(ctx, input));
805
1045
  }),
806
- schemas: publicProcedure.query(async ({ ctx }) => {
807
- return fetchOpsxSchemas(ctx);
1046
+ configBundle: publicProcedure.query(async ({ ctx }) => {
1047
+ return fetchOpsxConfigBundle(ctx);
808
1048
  }),
809
- subscribeSchemas: publicProcedure.subscription(({ ctx }) => {
810
- return createReactiveSubscription(() => fetchOpsxSchemas(ctx));
1049
+ subscribeConfigBundle: publicProcedure.subscription(({ ctx }) => {
1050
+ return createReactiveSubscription(() => fetchOpsxConfigBundle(ctx));
811
1051
  }),
812
1052
  templates: publicProcedure.input(z.object({ schema: z.string().optional() }).optional()).query(async ({ ctx, input }) => {
813
1053
  return fetchOpsxTemplates(ctx, input?.schema);
@@ -821,53 +1061,34 @@ const opsxRouter = router({
821
1061
  subscribeTemplateContents: publicProcedure.input(z.object({ schema: z.string().optional() }).optional()).subscription(({ ctx, input }) => {
822
1062
  return createReactiveSubscription(() => fetchOpsxTemplateContents(ctx, input?.schema));
823
1063
  }),
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
1064
  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);
1065
+ await ctx.kernel.waitForWarmup();
1066
+ await ctx.kernel.ensureSchemaFiles(input.name);
1067
+ return ctx.kernel.getSchemaFiles(input.name);
849
1068
  }),
850
1069
  subscribeSchemaFiles: publicProcedure.input(z.object({ name: z.string() })).subscription(({ ctx, input }) => {
851
1070
  return createReactiveSubscription(async () => {
852
- await touchOpsxProjectDeps(ctx.projectDir);
853
- return readEntriesUnderRoot((await fetchOpsxSchemaResolution(ctx, input.name)).path);
1071
+ await ctx.kernel.waitForWarmup();
1072
+ await ctx.kernel.ensureSchemaFiles(input.name);
1073
+ return ctx.kernel.getSchemaFiles(input.name);
854
1074
  });
855
1075
  }),
856
1076
  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"));
1077
+ await ctx.kernel.waitForWarmup();
1078
+ await ctx.kernel.ensureSchemaYaml(input.name);
1079
+ return ctx.kernel.getSchemaYaml(input.name);
859
1080
  }),
860
1081
  subscribeSchemaYaml: publicProcedure.input(z.object({ name: z.string() })).subscription(({ ctx, input }) => {
861
1082
  return createReactiveSubscription(async () => {
862
- await touchOpsxProjectDeps(ctx.projectDir);
863
- return reactiveReadFile(join((await fetchOpsxSchemaResolution(ctx, input.name)).path, "schema.yaml"));
1083
+ await ctx.kernel.waitForWarmup();
1084
+ await ctx.kernel.ensureSchemaYaml(input.name);
1085
+ return ctx.kernel.getSchemaYaml(input.name);
864
1086
  });
865
1087
  }),
866
1088
  writeSchemaYaml: publicProcedure.input(z.object({
867
1089
  name: z.string(),
868
1090
  content: z.string()
869
1091
  })).mutation(async ({ ctx, input }) => {
870
- await touchOpsxProjectDeps(ctx.projectDir);
871
1092
  const resolution = await fetchOpsxSchemaResolution(ctx, input.name);
872
1093
  ensureEditableSource(resolution.source, "schema.yaml");
873
1094
  const schemaPath = join(resolution.path, "schema.yaml");
@@ -880,7 +1101,6 @@ const opsxRouter = router({
880
1101
  path: z.string(),
881
1102
  content: z.string()
882
1103
  })).mutation(async ({ ctx, input }) => {
883
- await touchOpsxProjectDeps(ctx.projectDir);
884
1104
  const resolution = await fetchOpsxSchemaResolution(ctx, input.schema);
885
1105
  ensureEditableSource(resolution.source, "schema file");
886
1106
  if (!input.path.trim()) throw new Error("path is required");
@@ -894,7 +1114,6 @@ const opsxRouter = router({
894
1114
  path: z.string(),
895
1115
  content: z.string().optional()
896
1116
  })).mutation(async ({ ctx, input }) => {
897
- await touchOpsxProjectDeps(ctx.projectDir);
898
1117
  const resolution = await fetchOpsxSchemaResolution(ctx, input.schema);
899
1118
  ensureEditableSource(resolution.source, "schema file");
900
1119
  if (!input.path.trim()) throw new Error("path is required");
@@ -907,7 +1126,6 @@ const opsxRouter = router({
907
1126
  schema: z.string(),
908
1127
  path: z.string()
909
1128
  })).mutation(async ({ ctx, input }) => {
910
- await touchOpsxProjectDeps(ctx.projectDir);
911
1129
  const resolution = await fetchOpsxSchemaResolution(ctx, input.schema);
912
1130
  ensureEditableSource(resolution.source, "schema directory");
913
1131
  if (!input.path.trim()) throw new Error("path is required");
@@ -918,7 +1136,6 @@ const opsxRouter = router({
918
1136
  schema: z.string(),
919
1137
  path: z.string()
920
1138
  })).mutation(async ({ ctx, input }) => {
921
- await touchOpsxProjectDeps(ctx.projectDir);
922
1139
  const resolution = await fetchOpsxSchemaResolution(ctx, input.schema);
923
1140
  ensureEditableSource(resolution.source, "schema entry");
924
1141
  if (!input.path.trim()) throw new Error("path is required");
@@ -934,26 +1151,18 @@ const opsxRouter = router({
934
1151
  schema: z.string(),
935
1152
  artifactId: z.string()
936
1153
  })).query(async ({ ctx, input }) => {
937
- const info = (await fetchOpsxTemplates(ctx, input.schema))[input.artifactId];
1154
+ const info = (await fetchOpsxTemplateContents(ctx, input.schema))[input.artifactId];
938
1155
  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
- };
1156
+ return info;
944
1157
  }),
945
1158
  subscribeTemplateContent: publicProcedure.input(z.object({
946
1159
  schema: z.string(),
947
1160
  artifactId: z.string()
948
1161
  })).subscription(({ ctx, input }) => {
949
1162
  return createReactiveSubscription(async () => {
950
- const info = (await fetchOpsxTemplates(ctx, input.schema))[input.artifactId];
1163
+ const info = (await fetchOpsxTemplateContents(ctx, input.schema))[input.artifactId];
951
1164
  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
- };
1165
+ return info;
957
1166
  });
958
1167
  }),
959
1168
  writeTemplateContent: publicProcedure.input(z.object({
@@ -969,7 +1178,6 @@ const opsxRouter = router({
969
1178
  return { success: true };
970
1179
  }),
971
1180
  deleteSchema: publicProcedure.input(z.object({ name: z.string() })).mutation(async ({ ctx, input }) => {
972
- await touchOpsxProjectDeps(ctx.projectDir);
973
1181
  const resolution = await fetchOpsxSchemaResolution(ctx, input.name);
974
1182
  ensureEditableSource(resolution.source, "schema");
975
1183
  await rm(resolution.path, {
@@ -979,11 +1187,15 @@ const opsxRouter = router({
979
1187
  return { success: true };
980
1188
  }),
981
1189
  projectConfig: publicProcedure.query(async ({ ctx }) => {
982
- return reactiveReadFile(join(ctx.projectDir, "openspec", "config.yaml"));
1190
+ await ctx.kernel.waitForWarmup();
1191
+ await ctx.kernel.ensureProjectConfig();
1192
+ return ctx.kernel.getProjectConfig();
983
1193
  }),
984
1194
  subscribeProjectConfig: publicProcedure.subscription(({ ctx }) => {
985
1195
  return createReactiveSubscription(async () => {
986
- return reactiveReadFile(join(ctx.projectDir, "openspec", "config.yaml"));
1196
+ await ctx.kernel.waitForWarmup();
1197
+ await ctx.kernel.ensureProjectConfig();
1198
+ return ctx.kernel.getProjectConfig();
987
1199
  });
988
1200
  }),
989
1201
  writeProjectConfig: publicProcedure.input(z.object({ content: z.string() })).mutation(async ({ ctx, input }) => {
@@ -993,55 +1205,63 @@ const opsxRouter = router({
993
1205
  return { success: true };
994
1206
  }),
995
1207
  listChanges: publicProcedure.query(async ({ ctx }) => {
996
- return reactiveReadDir(join(ctx.projectDir, "openspec", "changes"), {
997
- directoriesOnly: true,
998
- exclude: ["archive"],
999
- includeHidden: false
1000
- });
1208
+ await ctx.kernel.waitForWarmup();
1209
+ await ctx.kernel.ensureChangeIds();
1210
+ return ctx.kernel.getChangeIds();
1001
1211
  }),
1002
1212
  subscribeChanges: publicProcedure.subscription(({ ctx }) => {
1003
1213
  return createReactiveSubscription(async () => {
1004
- return reactiveReadDir(join(ctx.projectDir, "openspec", "changes"), {
1005
- directoriesOnly: true,
1006
- exclude: ["archive"],
1007
- includeHidden: false
1008
- });
1214
+ await ctx.kernel.waitForWarmup();
1215
+ await ctx.kernel.ensureChangeIds();
1216
+ return ctx.kernel.getChangeIds();
1009
1217
  });
1010
1218
  }),
1011
1219
  changeMetadata: publicProcedure.input(z.object({ changeId: z.string() })).query(async ({ ctx, input }) => {
1012
- return reactiveReadFile(join(ctx.projectDir, "openspec", "changes", input.changeId, ".openspec.yaml"));
1220
+ await ctx.kernel.waitForWarmup();
1221
+ await ctx.kernel.ensureChangeMetadata(input.changeId);
1222
+ return ctx.kernel.getChangeMetadata(input.changeId);
1013
1223
  }),
1014
1224
  subscribeChangeMetadata: publicProcedure.input(z.object({ changeId: z.string() })).subscription(({ ctx, input }) => {
1015
1225
  return createReactiveSubscription(async () => {
1016
- return reactiveReadFile(join(ctx.projectDir, "openspec", "changes", input.changeId, ".openspec.yaml"));
1226
+ await ctx.kernel.waitForWarmup();
1227
+ await ctx.kernel.ensureChangeMetadata(input.changeId);
1228
+ return ctx.kernel.getChangeMetadata(input.changeId);
1017
1229
  });
1018
1230
  }),
1019
1231
  readArtifactOutput: publicProcedure.input(z.object({
1020
1232
  changeId: z.string(),
1021
1233
  outputPath: z.string()
1022
1234
  })).query(async ({ ctx, input }) => {
1023
- return reactiveReadFile(join(ctx.projectDir, "openspec", "changes", input.changeId, input.outputPath));
1235
+ await ctx.kernel.waitForWarmup();
1236
+ await ctx.kernel.ensureArtifactOutput(input.changeId, input.outputPath);
1237
+ return ctx.kernel.getArtifactOutput(input.changeId, input.outputPath);
1024
1238
  }),
1025
1239
  subscribeArtifactOutput: publicProcedure.input(z.object({
1026
1240
  changeId: z.string(),
1027
1241
  outputPath: z.string()
1028
1242
  })).subscription(({ ctx, input }) => {
1029
1243
  return createReactiveSubscription(async () => {
1030
- return reactiveReadFile(join(ctx.projectDir, "openspec", "changes", input.changeId, input.outputPath));
1244
+ await ctx.kernel.waitForWarmup();
1245
+ await ctx.kernel.ensureArtifactOutput(input.changeId, input.outputPath);
1246
+ return ctx.kernel.getArtifactOutput(input.changeId, input.outputPath);
1031
1247
  });
1032
1248
  }),
1033
1249
  readGlobArtifactFiles: publicProcedure.input(z.object({
1034
1250
  changeId: z.string(),
1035
1251
  outputPath: z.string()
1036
1252
  })).query(async ({ ctx, input }) => {
1037
- return readGlobArtifactFiles(ctx.projectDir, input.changeId, input.outputPath);
1253
+ await ctx.kernel.waitForWarmup();
1254
+ await ctx.kernel.ensureGlobArtifactFiles(input.changeId, input.outputPath);
1255
+ return ctx.kernel.getGlobArtifactFiles(input.changeId, input.outputPath);
1038
1256
  }),
1039
1257
  subscribeGlobArtifactFiles: publicProcedure.input(z.object({
1040
1258
  changeId: z.string(),
1041
1259
  outputPath: z.string()
1042
1260
  })).subscription(({ ctx, input }) => {
1043
1261
  return createReactiveSubscription(async () => {
1044
- return readGlobArtifactFiles(ctx.projectDir, input.changeId, input.outputPath);
1262
+ await ctx.kernel.waitForWarmup();
1263
+ await ctx.kernel.ensureGlobArtifactFiles(input.changeId, input.outputPath);
1264
+ return ctx.kernel.getGlobArtifactFiles(input.changeId, input.outputPath);
1045
1265
  });
1046
1266
  }),
1047
1267
  writeArtifactOutput: publicProcedure.input(z.object({
@@ -1086,6 +1306,17 @@ const kvRouter = router({
1086
1306
  })
1087
1307
  });
1088
1308
  /**
1309
+ * Search router - unified fulltext search over specs/changes/archives
1310
+ */
1311
+ const searchRouter = router({
1312
+ query: publicProcedure.input(SearchQuerySchema).query(async ({ ctx, input }) => {
1313
+ return ctx.searchService.query(input);
1314
+ }),
1315
+ subscribe: publicProcedure.input(SearchQuerySchema).subscription(({ ctx, input }) => {
1316
+ return createReactiveSubscriptionWithInput((queryInput) => ctx.searchService.queryReactive(queryInput))(input);
1317
+ })
1318
+ });
1319
+ /**
1089
1320
  * Main app router
1090
1321
  */
1091
1322
  const appRouter = router({
@@ -1095,402 +1326,145 @@ const appRouter = router({
1095
1326
  init: initRouter,
1096
1327
  realtime: realtimeRouter,
1097
1328
  config: configRouter,
1098
- cli: cliRouter,
1099
- opsx: opsxRouter,
1100
- kv: kvRouter
1101
- });
1102
-
1103
- //#endregion
1104
- //#region src/port-utils.ts
1105
- /**
1106
- * Check if a port is available by trying to listen on it.
1107
- * Uses default binding (both IPv4 and IPv6) to detect conflicts.
1108
- */
1109
- function isPortAvailable(port) {
1110
- return new Promise((resolve$1) => {
1111
- const server = createServer$1();
1112
- server.once("error", () => {
1113
- resolve$1(false);
1114
- });
1115
- server.once("listening", () => {
1116
- server.close(() => resolve$1(true));
1117
- });
1118
- server.listen(port);
1119
- });
1120
- }
1121
- /**
1122
- * Find an available port starting from the given port.
1123
- * Will try up to maxAttempts ports sequentially.
1124
- *
1125
- * @param startPort - The preferred port to start checking from
1126
- * @param maxAttempts - Maximum number of ports to try (default: 10)
1127
- * @returns The first available port found
1128
- * @throws Error if no available port is found in the range
1129
- */
1130
- async function findAvailablePort(startPort, maxAttempts = 10) {
1131
- for (let i = 0; i < maxAttempts; i++) {
1132
- const port = startPort + i;
1133
- if (await isPortAvailable(port)) return port;
1134
- }
1135
- throw new Error(`No available port found in range ${startPort}-${startPort + maxAttempts - 1}`);
1136
- }
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
- function resolveDefaultShell(platform, env) {
1148
- if (platform === "windows") return env.ComSpec?.trim() || "cmd.exe";
1149
- return env.SHELL?.trim() || "/bin/sh";
1150
- }
1151
- function resolvePtyCommand(opts) {
1152
- const command = opts.command?.trim();
1153
- if (command) return {
1154
- command,
1155
- args: opts.args ?? []
1156
- };
1157
- return {
1158
- command: resolveDefaultShell(opts.platform, opts.env),
1159
- args: []
1160
- };
1161
- }
1162
- var PtySession = class extends EventEmitter$1 {
1163
- id;
1164
- command;
1165
- args;
1166
- platform;
1167
- createdAt;
1168
- process;
1169
- titleInterval = null;
1170
- lastTitle = "";
1171
- buffer = [];
1172
- bufferByteLength = 0;
1173
- maxBufferLines;
1174
- maxBufferBytes;
1175
- isExited = false;
1176
- exitCode = null;
1177
- constructor(id, opts) {
1178
- super();
1179
- this.id = id;
1180
- this.createdAt = Date.now();
1181
- const resolvedCommand = resolvePtyCommand({
1182
- platform: opts.platform,
1183
- command: opts.command,
1184
- args: opts.args,
1185
- env: process.env
1186
- });
1187
- this.command = resolvedCommand.command;
1188
- this.args = resolvedCommand.args;
1189
- this.platform = opts.platform;
1190
- this.maxBufferLines = opts.scrollback ?? DEFAULT_SCROLLBACK;
1191
- this.maxBufferBytes = opts.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
1192
- this.process = pty.spawn(this.command, this.args, {
1193
- name: "xterm-256color",
1194
- cols: opts.cols ?? 80,
1195
- rows: opts.rows ?? 24,
1196
- cwd: opts.cwd,
1197
- env: {
1198
- ...process.env,
1199
- TERM: "xterm-256color"
1200
- }
1201
- });
1202
- this.process.onData((data) => {
1203
- this.appendBuffer(data);
1204
- this.emit("data", data);
1205
- });
1206
- this.process.onExit(({ exitCode }) => {
1207
- if (this.titleInterval) {
1208
- clearInterval(this.titleInterval);
1209
- this.titleInterval = null;
1210
- }
1211
- this.isExited = true;
1212
- this.exitCode = exitCode;
1213
- this.emit("exit", exitCode);
1214
- });
1215
- this.titleInterval = setInterval(() => {
1216
- try {
1217
- const title = this.process.process;
1218
- if (title && title !== this.lastTitle) {
1219
- this.lastTitle = title;
1220
- this.emit("title", title);
1221
- }
1222
- } catch {}
1223
- }, 1e3);
1224
- }
1225
- get title() {
1226
- return this.lastTitle;
1227
- }
1228
- appendBuffer(data) {
1229
- let chunk = data;
1230
- if (chunk.length > this.maxBufferBytes) chunk = chunk.slice(-this.maxBufferBytes);
1231
- this.buffer.push(chunk);
1232
- this.bufferByteLength += chunk.length;
1233
- while (this.bufferByteLength > this.maxBufferBytes && this.buffer.length > 0) {
1234
- const removed = this.buffer.shift();
1235
- this.bufferByteLength -= removed.length;
1236
- }
1237
- while (this.buffer.length > this.maxBufferLines) {
1238
- const removed = this.buffer.shift();
1239
- this.bufferByteLength -= removed.length;
1240
- }
1329
+ cli: cliRouter,
1330
+ opsx: opsxRouter,
1331
+ kv: kvRouter,
1332
+ search: searchRouter
1333
+ });
1334
+
1335
+ //#endregion
1336
+ //#region src/search-documents.ts
1337
+ function joinParts(parts) {
1338
+ return parts.map((part) => part?.trim() ?? "").filter((part) => part.length > 0).join("\n\n");
1339
+ }
1340
+ async function collectSearchDocuments(adapter) {
1341
+ const docs = [];
1342
+ const specs = await adapter.listSpecsWithMeta();
1343
+ for (const spec of specs) {
1344
+ const raw = await adapter.readSpecRaw(spec.id);
1345
+ if (!raw) continue;
1346
+ docs.push({
1347
+ id: `spec:${spec.id}`,
1348
+ kind: "spec",
1349
+ title: spec.name,
1350
+ href: `/specs/${encodeURIComponent(spec.id)}`,
1351
+ path: `openspec/specs/${spec.id}/spec.md`,
1352
+ content: raw,
1353
+ updatedAt: spec.updatedAt
1354
+ });
1241
1355
  }
1242
- getBuffer() {
1243
- return this.buffer.join("");
1356
+ const changes = await adapter.listChangesWithMeta();
1357
+ for (const change of changes) {
1358
+ const raw = await adapter.readChangeRaw(change.id);
1359
+ if (!raw) continue;
1360
+ docs.push({
1361
+ id: `change:${change.id}`,
1362
+ kind: "change",
1363
+ title: change.name,
1364
+ href: `/changes/${encodeURIComponent(change.id)}`,
1365
+ path: `openspec/changes/${change.id}`,
1366
+ content: joinParts([
1367
+ raw.proposal,
1368
+ raw.tasks,
1369
+ raw.design,
1370
+ ...raw.deltaSpecs.map((deltaSpec) => deltaSpec.content)
1371
+ ]),
1372
+ updatedAt: change.updatedAt
1373
+ });
1244
1374
  }
1245
- write(data) {
1246
- if (!this.isExited) this.process.write(data);
1375
+ const archives = await adapter.listArchivedChangesWithMeta();
1376
+ for (const archive of archives) {
1377
+ const raw = await adapter.readArchivedChangeRaw(archive.id);
1378
+ if (!raw) continue;
1379
+ docs.push({
1380
+ id: `archive:${archive.id}`,
1381
+ kind: "archive",
1382
+ title: archive.name,
1383
+ href: `/archive/${encodeURIComponent(archive.id)}`,
1384
+ path: `openspec/changes/archive/${archive.id}`,
1385
+ content: joinParts([
1386
+ raw.proposal,
1387
+ raw.tasks,
1388
+ raw.design,
1389
+ ...raw.deltaSpecs.map((deltaSpec) => deltaSpec.content)
1390
+ ]),
1391
+ updatedAt: archive.updatedAt
1392
+ });
1247
1393
  }
1248
- resize(cols, rows) {
1249
- if (!this.isExited) this.process.resize(cols, rows);
1394
+ return docs;
1395
+ }
1396
+
1397
+ //#endregion
1398
+ //#region src/search-service.ts
1399
+ const REBUILD_DEBOUNCE_MS = 250;
1400
+ var SearchService = class {
1401
+ provider;
1402
+ initialized = false;
1403
+ initPromise = null;
1404
+ rebuildPromise = null;
1405
+ rebuildTimer = null;
1406
+ constructor(adapter, watcher, provider = new NodeWorkerSearchProvider()) {
1407
+ this.adapter = adapter;
1408
+ this.provider = provider;
1409
+ watcher?.on("change", () => {
1410
+ this.scheduleRebuild();
1411
+ });
1250
1412
  }
1251
- close() {
1252
- if (this.titleInterval) {
1253
- clearInterval(this.titleInterval);
1254
- this.titleInterval = null;
1255
- }
1413
+ async init() {
1414
+ if (this.initialized) return;
1415
+ if (this.initPromise) return this.initPromise;
1416
+ this.initPromise = this.rebuildIndex(true);
1256
1417
  try {
1257
- this.process.kill();
1258
- } catch {}
1259
- this.removeAllListeners();
1260
- }
1261
- toInfo() {
1262
- return {
1263
- id: this.id,
1264
- title: this.lastTitle,
1265
- command: this.command,
1266
- args: this.args,
1267
- platform: this.platform,
1268
- isExited: this.isExited,
1269
- exitCode: this.exitCode,
1270
- createdAt: this.createdAt
1271
- };
1272
- }
1273
- };
1274
- var PtyManager = class {
1275
- sessions = /* @__PURE__ */ new Map();
1276
- idCounter = 0;
1277
- platform;
1278
- constructor(defaultCwd) {
1279
- this.defaultCwd = defaultCwd;
1280
- this.platform = detectPtyPlatform();
1418
+ await this.initPromise;
1419
+ } finally {
1420
+ this.initPromise = null;
1421
+ }
1281
1422
  }
1282
- create(opts) {
1283
- const id = `pty-${++this.idCounter}`;
1284
- const session = new PtySession(id, {
1285
- cols: opts.cols,
1286
- rows: opts.rows,
1287
- command: opts.command,
1288
- args: opts.args,
1289
- cwd: this.defaultCwd,
1290
- scrollback: opts.scrollback,
1291
- maxBufferBytes: opts.maxBufferBytes,
1292
- platform: this.platform
1293
- });
1294
- this.sessions.set(id, session);
1295
- return session;
1423
+ async query(input) {
1424
+ const parsed = SearchQuerySchema.parse(input);
1425
+ await this.init();
1426
+ return this.provider.search(parsed);
1296
1427
  }
1297
- get(id) {
1298
- return this.sessions.get(id);
1428
+ async queryReactive(input) {
1429
+ const parsed = SearchQuerySchema.parse(input);
1430
+ await this.rebuildIndex();
1431
+ return this.provider.search(parsed);
1299
1432
  }
1300
- list() {
1301
- const result = [];
1302
- for (const session of this.sessions.values()) result.push(session.toInfo());
1303
- return result;
1433
+ async dispose() {
1434
+ this.cancelRebuild();
1435
+ await this.provider.dispose();
1304
1436
  }
1305
- write(id, data) {
1306
- this.sessions.get(id)?.write(data);
1437
+ scheduleRebuild() {
1438
+ this.cancelRebuild();
1439
+ this.rebuildTimer = setTimeout(() => {
1440
+ this.rebuildTimer = null;
1441
+ this.rebuildIndex().catch(() => {});
1442
+ }, REBUILD_DEBOUNCE_MS);
1307
1443
  }
1308
- resize(id, cols, rows) {
1309
- this.sessions.get(id)?.resize(cols, rows);
1444
+ cancelRebuild() {
1445
+ if (!this.rebuildTimer) return;
1446
+ clearTimeout(this.rebuildTimer);
1447
+ this.rebuildTimer = null;
1310
1448
  }
1311
- close(id) {
1312
- const session = this.sessions.get(id);
1313
- if (session) {
1314
- session.close();
1315
- this.sessions.delete(id);
1449
+ async rebuildIndex(forceInit = false) {
1450
+ if (!forceInit && !this.initialized) return;
1451
+ if (this.rebuildPromise) return this.rebuildPromise;
1452
+ this.rebuildPromise = (async () => {
1453
+ const docs = await collectSearchDocuments(this.adapter);
1454
+ if (this.initialized) await this.provider.replaceAll(docs);
1455
+ else {
1456
+ await this.provider.init(docs);
1457
+ this.initialized = true;
1458
+ }
1459
+ })();
1460
+ try {
1461
+ await this.rebuildPromise;
1462
+ } finally {
1463
+ this.rebuildPromise = null;
1316
1464
  }
1317
1465
  }
1318
- closeAll() {
1319
- for (const session of this.sessions.values()) session.close();
1320
- this.sessions.clear();
1321
- }
1322
1466
  };
1323
1467
 
1324
- //#endregion
1325
- //#region src/pty-websocket.ts
1326
- function createPtyWebSocketHandler(ptyManager) {
1327
- return (ws) => {
1328
- const cleanups = /* @__PURE__ */ new Map();
1329
- const send = (msg) => {
1330
- if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
1331
- };
1332
- const sendError = (code, message, opts) => {
1333
- send({
1334
- type: "error",
1335
- code,
1336
- message,
1337
- sessionId: opts?.sessionId
1338
- });
1339
- };
1340
- const attachToSession = (session, opts) => {
1341
- const sessionId = session.id;
1342
- cleanups.get(sessionId)?.();
1343
- if (opts?.cols && opts?.rows && !session.isExited) session.resize(opts.cols, opts.rows);
1344
- const onData = (data) => {
1345
- send({
1346
- type: "output",
1347
- sessionId,
1348
- data
1349
- });
1350
- };
1351
- const onExit = (exitCode) => {
1352
- send({
1353
- type: "exit",
1354
- sessionId,
1355
- exitCode
1356
- });
1357
- };
1358
- const onTitle = (title) => {
1359
- send({
1360
- type: "title",
1361
- sessionId,
1362
- title
1363
- });
1364
- };
1365
- session.on("data", onData);
1366
- session.on("exit", onExit);
1367
- session.on("title", onTitle);
1368
- cleanups.set(sessionId, () => {
1369
- session.removeListener("data", onData);
1370
- session.removeListener("exit", onExit);
1371
- session.removeListener("title", onTitle);
1372
- cleanups.delete(sessionId);
1373
- });
1374
- };
1375
- ws.on("message", (raw) => {
1376
- let parsed;
1377
- try {
1378
- parsed = JSON.parse(String(raw));
1379
- } catch {
1380
- sendError("INVALID_JSON", "Invalid JSON payload");
1381
- return;
1382
- }
1383
- const parsedMessage = PtyClientMessageSchema.safeParse(parsed);
1384
- if (!parsedMessage.success) {
1385
- const firstIssue = parsedMessage.error.issues[0]?.message;
1386
- sendError("INVALID_MESSAGE", firstIssue ?? "Invalid PTY message");
1387
- return;
1388
- }
1389
- const msg = parsedMessage.data;
1390
- switch (msg.type) {
1391
- case "create":
1392
- try {
1393
- const session = ptyManager.create({
1394
- cols: msg.cols,
1395
- rows: msg.rows,
1396
- command: msg.command,
1397
- args: msg.args
1398
- });
1399
- send({
1400
- type: "created",
1401
- requestId: msg.requestId,
1402
- sessionId: session.id,
1403
- platform: session.platform
1404
- });
1405
- attachToSession(session);
1406
- } catch (err) {
1407
- sendError("PTY_CREATE_FAILED", err instanceof Error ? err.message : String(err), { sessionId: msg.requestId });
1408
- }
1409
- break;
1410
- case "attach": {
1411
- const session = ptyManager.get(msg.sessionId);
1412
- if (!session) {
1413
- sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
1414
- send({
1415
- type: "exit",
1416
- sessionId: msg.sessionId,
1417
- exitCode: -1
1418
- });
1419
- break;
1420
- }
1421
- attachToSession(session, {
1422
- cols: msg.cols,
1423
- rows: msg.rows
1424
- });
1425
- const buffer = session.getBuffer();
1426
- if (buffer) send({
1427
- type: "buffer",
1428
- sessionId: session.id,
1429
- data: buffer
1430
- });
1431
- if (session.title) send({
1432
- type: "title",
1433
- sessionId: session.id,
1434
- title: session.title
1435
- });
1436
- if (session.isExited) send({
1437
- type: "exit",
1438
- sessionId: session.id,
1439
- exitCode: session.exitCode ?? -1
1440
- });
1441
- break;
1442
- }
1443
- case "list":
1444
- send({
1445
- type: "list",
1446
- sessions: ptyManager.list().map((s) => ({
1447
- id: s.id,
1448
- title: s.title,
1449
- command: s.command,
1450
- args: s.args,
1451
- platform: s.platform,
1452
- isExited: s.isExited,
1453
- exitCode: s.exitCode
1454
- }))
1455
- });
1456
- break;
1457
- case "input": {
1458
- const session = ptyManager.get(msg.sessionId);
1459
- if (!session) {
1460
- sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
1461
- break;
1462
- }
1463
- session.write(msg.data);
1464
- break;
1465
- }
1466
- case "resize": {
1467
- const session = ptyManager.get(msg.sessionId);
1468
- if (!session) {
1469
- sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
1470
- break;
1471
- }
1472
- session.resize(msg.cols, msg.rows);
1473
- break;
1474
- }
1475
- case "close": {
1476
- const session = ptyManager.get(msg.sessionId);
1477
- if (!session) {
1478
- sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
1479
- break;
1480
- }
1481
- cleanups.get(msg.sessionId)?.();
1482
- ptyManager.close(session.id);
1483
- break;
1484
- }
1485
- }
1486
- });
1487
- ws.on("close", () => {
1488
- for (const cleanup of cleanups.values()) cleanup();
1489
- cleanups.clear();
1490
- });
1491
- };
1492
- }
1493
-
1494
1468
  //#endregion
1495
1469
  //#region src/server.ts
1496
1470
  /**
@@ -1514,6 +1488,7 @@ function createServer(config) {
1514
1488
  const cliExecutor = new CliExecutor(configManager, config.projectDir);
1515
1489
  const kernel = config.kernel;
1516
1490
  const watcher = config.enableWatcher !== false ? new OpenSpecWatcher(config.projectDir) : void 0;
1491
+ const searchService = new SearchService(adapter, watcher);
1517
1492
  const app = new Hono();
1518
1493
  const corsOrigins = config.corsOrigins ?? ["http://localhost:5173", "http://localhost:3000"];
1519
1494
  app.use("*", cors({
@@ -1537,6 +1512,7 @@ function createServer(config) {
1537
1512
  configManager,
1538
1513
  cliExecutor,
1539
1514
  kernel,
1515
+ searchService,
1540
1516
  watcher,
1541
1517
  projectDir: config.projectDir
1542
1518
  })
@@ -1547,6 +1523,7 @@ function createServer(config) {
1547
1523
  configManager,
1548
1524
  cliExecutor,
1549
1525
  kernel,
1526
+ searchService,
1550
1527
  watcher,
1551
1528
  projectDir: config.projectDir
1552
1529
  });
@@ -1556,6 +1533,7 @@ function createServer(config) {
1556
1533
  configManager,
1557
1534
  cliExecutor,
1558
1535
  kernel,
1536
+ searchService,
1559
1537
  watcher,
1560
1538
  createContext,
1561
1539
  port: config.port ?? 3100
@@ -1597,6 +1575,7 @@ async function createWebSocketServer(server, httpServer, config) {
1597
1575
  ptyWss.close();
1598
1576
  wss.close();
1599
1577
  server.watcher?.stop();
1578
+ server.searchService.dispose().catch(() => {});
1600
1579
  }
1601
1580
  };
1602
1581
  }
@@ -1629,6 +1608,9 @@ async function startServer(config, setupApp) {
1629
1608
  kernel.warmup().catch((err) => {
1630
1609
  console.error("Kernel warmup failed:", err);
1631
1610
  });
1611
+ server.searchService.init().catch((err) => {
1612
+ console.error("Search service warmup failed:", err);
1613
+ });
1632
1614
  return {
1633
1615
  url,
1634
1616
  port,