@ai-setting/roy-plugin-task-show 2.4.2 → 2.5.1

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.
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
package/dist/server.js CHANGED
@@ -36,6 +36,9 @@ import { TracedAs } from "./tracing/decorator.js";
36
36
  import { buildSessionFileTree, } from "./file-tree.js";
37
37
  import { renderToolCallDetail, } from "./tool-call-detail.js";
38
38
  import { renderSessionForestPage, } from "./task-session-store.js";
39
+ import { ChatProcessManager } from "./chat-process-manager.js";
40
+ import { renderChatPanel } from "./chat-panel-html.js";
41
+ import { TaskChatContextBuilder } from "./task-chat-context-builder.js";
39
42
  /** Upper bound of the TCP user port range. Probing never wraps past this. */
40
43
  export const MAX_TCP_PORT = 65535;
41
44
  /** Smallest legal TCP user port (avoid binding to well-known 0..1023). */
@@ -177,6 +180,29 @@ function getDirname(metaUrl) {
177
180
  return path.dirname(fileURLToPath(metaUrl));
178
181
  return typeof __dirname !== "undefined" ? __dirname : process.cwd();
179
182
  }
183
+ /**
184
+ * Read the request body and parse it as JSON. Returns an empty object
185
+ * when the body is empty or non-JSON. Used by the chat routes to
186
+ * gracefully handle curl probes that omit Content-Type.
187
+ */
188
+ async function readJsonBody(req) {
189
+ return new Promise((resolve) => {
190
+ const chunks = [];
191
+ req.on("data", (c) => chunks.push(c));
192
+ req.on("end", () => {
193
+ const raw = Buffer.concat(chunks).toString("utf-8").trim();
194
+ if (!raw)
195
+ return resolve({});
196
+ try {
197
+ resolve(JSON.parse(raw));
198
+ }
199
+ catch {
200
+ resolve({});
201
+ }
202
+ });
203
+ req.on("error", () => resolve({}));
204
+ });
205
+ }
180
206
  // ============================================================================
181
207
  // TaskShowServer
182
208
  // ============================================================================
@@ -202,6 +228,8 @@ export class TaskShowServer {
202
228
  /** v0.9.0+: optional session-scoped task store. When set, GET / renders the
203
229
  * session forest; otherwise the legacy cross-host tree view is used. */
204
230
  sessionStore;
231
+ /** v2.5.0+: ChatProcessManager singleton (null when chat is disabled). */
232
+ chatManager;
205
233
  constructor(opts) {
206
234
  this.cfg = opts.cfg;
207
235
  this.collector = opts.collector;
@@ -212,6 +240,18 @@ export class TaskShowServer {
212
240
  this.tasksTreeCache = opts.tasksTreeCache ?? null;
213
241
  this.sessionStore = opts.sessionStore ?? null;
214
242
  this.pluginVersion = opts.pluginVersion ?? "1.1.0";
243
+ // v2.5.0+: ChatProcessManager is a per-server singleton. It owns the
244
+ // subprocess lifecycle and is shared across all /api/chat/* routes.
245
+ if (opts.chatOptions && opts.chatOptions.enabled !== false && opts.chatOptions.cliPath) {
246
+ this.chatManager = new ChatProcessManager({
247
+ cliPath: opts.chatOptions.cliPath,
248
+ maxConcurrent: opts.chatOptions.maxConcurrent ?? 4,
249
+ defaultTimeoutMs: opts.chatOptions.defaultTimeoutMs ?? 300_000,
250
+ });
251
+ }
252
+ else {
253
+ this.chatManager = null;
254
+ }
215
255
  }
216
256
  /**
217
257
  * Get the SSE event bus (for tests / health checks).
@@ -342,16 +382,57 @@ export class TaskShowServer {
342
382
  async handle(req, res) {
343
383
  const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
344
384
  const method = (req.method ?? "GET").toUpperCase();
345
- if (method !== "GET" && method !== "HEAD") {
346
- this.text(res, 405, "Method Not Allowed");
347
- return;
348
- }
349
385
  const pathname = url.pathname;
350
386
  // /api/events — SSE stream
351
387
  if (pathname === "/api/events") {
388
+ if (method !== "GET") {
389
+ this.text(res, 405, "Method Not Allowed");
390
+ return;
391
+ }
352
392
  this.handleSSE(req, res);
353
393
  return;
354
394
  }
395
+ // v2.5.0+: /api/chat/* — home-page chat. The /start endpoint also
396
+ // accepts GET so plain curl/EventSource probes work without a body.
397
+ if (pathname === "/api/chat/start") {
398
+ await this.handleChatStart(req, res);
399
+ return;
400
+ }
401
+ const chatMsgMatch = pathname.match(/^\/api\/chat\/([^/]+)\/message$/);
402
+ if (chatMsgMatch) {
403
+ const sid = decodeURIComponent(chatMsgMatch[1]);
404
+ await this.handleChatMessage(sid, req, res);
405
+ return;
406
+ }
407
+ const chatAbortMatch = pathname.match(/^\/api\/chat\/([^/]+)\/abort$/);
408
+ if (chatAbortMatch) {
409
+ const sid = decodeURIComponent(chatAbortMatch[1]);
410
+ this.handleChatAbort(sid, res);
411
+ return;
412
+ }
413
+ // v2.5.0+ REQ-3: /api/task-chat/:taskId/message — task-aware chat.
414
+ // Same SSE protocol as /api/chat/:id/message but the subprocess
415
+ // sees a `task-${taskId}` sessionId (deterministic, long-lived)
416
+ // and the user message is wrapped with an additionInfo block
417
+ // describing the task context. The taskId itself must exist in
418
+ // either the collector OR the operations cache — otherwise we
419
+ // 404 instead of silently answering off-topic.
420
+ const taskChatMatch = pathname.match(/^\/api\/task-chat\/([^/]+)\/message$/);
421
+ if (taskChatMatch) {
422
+ const rawId = decodeURIComponent(taskChatMatch[1]);
423
+ const numericId = Number(rawId);
424
+ if (!Number.isFinite(numericId)) {
425
+ this.text(res, 400, `Invalid task id: ${rawId}`);
426
+ return;
427
+ }
428
+ await this.handleTaskChatMessage(numericId, req, res);
429
+ return;
430
+ }
431
+ // All remaining routes are GET/HEAD only.
432
+ if (method !== "GET" && method !== "HEAD") {
433
+ this.text(res, 405, "Method Not Allowed");
434
+ return;
435
+ }
355
436
  // /api/sessions
356
437
  if (pathname === "/api/sessions") {
357
438
  this.json(res, 200, this.collector.listSessions());
@@ -461,6 +542,301 @@ export class TaskShowServer {
461
542
  * directories (defaults to cwd + $HOME; configurable via
462
543
  * `ts.config.fileSandboxPaths`).
463
544
  */
545
+ // -------------------------------------------------------------------------
546
+ // v2.5.0+ chat handlers — POST/GET /api/chat/start,
547
+ // POST /api/chat/:id/message (SSE), POST /api/chat/:id/abort.
548
+ //
549
+ // The actual subprocess orchestration lives in `ChatProcessManager`
550
+ // (src/chat-process-manager.ts). These handlers translate HTTP + SSE
551
+ // concerns into chunk callbacks.
552
+ // -------------------------------------------------------------------------
553
+ /**
554
+ * Allocate a fresh chat sessionId. The id is just a string — the
555
+ * underlying SQLite store lives inside the `roy-agent act` subprocess
556
+ * (we never persist anything ourselves; on the next `act -s <id>` call
557
+ * the CLI resurrects the session from disk if it still exists).
558
+ */
559
+ handleChatStart(req, res) {
560
+ if (!this.chatManager) {
561
+ this.json(res, 503, { error: "chat_disabled", message: "Chat feature is not enabled on this server." });
562
+ return;
563
+ }
564
+ const method = (req.method ?? "GET").toUpperCase();
565
+ if (method !== "POST" && method !== "GET" && method !== "HEAD") {
566
+ this.text(res, 405, "Method Not Allowed");
567
+ return;
568
+ }
569
+ // Use crypto.randomUUID when available; fall back to time + random.
570
+ const id = typeof globalThis.crypto?.randomUUID === "function"
571
+ ? globalThis.crypto.randomUUID()
572
+ : `sess_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
573
+ const sessionId = `session_${id}`;
574
+ this.json(res, 200, {
575
+ sessionId,
576
+ createdAt: new Date().toISOString(),
577
+ });
578
+ }
579
+ /**
580
+ * SSE endpoint. Accepts `application/json` POST body and emits a series
581
+ * of SSE frames: `event: chunk` for every ChatChunk, then
582
+ * `event: done` once the subprocess exits (or `event: error` on
583
+ * failure).
584
+ */
585
+ async handleChatMessage(sessionId, req, res) {
586
+ if (!this.chatManager) {
587
+ this.json(res, 503, { error: "chat_disabled" });
588
+ return;
589
+ }
590
+ const method = (req.method ?? "GET").toUpperCase();
591
+ if (method !== "POST" && method !== "GET") {
592
+ this.text(res, 405, "Method Not Allowed");
593
+ return;
594
+ }
595
+ let message = "";
596
+ if (method === "POST") {
597
+ const body = await readJsonBody(req);
598
+ if (body && typeof body === "object" && typeof body.message === "string") {
599
+ message = body.message;
600
+ }
601
+ }
602
+ if (!message && method === "GET") {
603
+ message = "";
604
+ }
605
+ if (typeof message !== "string" || message.length === 0) {
606
+ this.json(res, 400, { error: "bad_request", message: "Missing `message` field in JSON body." });
607
+ return;
608
+ }
609
+ // SSE headers.
610
+ res.statusCode = 200;
611
+ res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
612
+ res.setHeader("Cache-Control", "no-cache, no-transform");
613
+ res.setHeader("Connection", "keep-alive");
614
+ res.setHeader("X-Accel-Buffering", "no");
615
+ res.flushHeaders?.();
616
+ let chunkIndex = 0;
617
+ const sendFrame = (event, payload) => {
618
+ if (res.writableEnded || res.destroyed)
619
+ return;
620
+ const id = ++chunkIndex;
621
+ try {
622
+ res.write(`id: ${id}\nevent: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
623
+ }
624
+ catch {
625
+ /* socket closed mid-write */
626
+ }
627
+ };
628
+ // Initial heartbeat so the client knows the connection is live before
629
+ // the subprocess warms up.
630
+ sendFrame("ready", { sessionId });
631
+ try {
632
+ const result = await this.chatManager.sendStream(sessionId, message, (chunk) => {
633
+ sendFrame("chunk", chunk);
634
+ if (chunk.type === "done" || chunk.type === "error") {
635
+ // Mirror the terminal event on its own line so EventSource
636
+ // listeners can register `addEventListener("done", ...)`.
637
+ sendFrame(chunk.type, chunk);
638
+ }
639
+ });
640
+ sendFrame("done", { sessionId, exitCode: result.exitCode, durationMs: result.durationMs });
641
+ }
642
+ catch (err) {
643
+ const msg = err instanceof Error ? err.message : String(err);
644
+ sendFrame("error", { sessionId, error: { message: msg } });
645
+ }
646
+ try {
647
+ res.end();
648
+ }
649
+ catch {
650
+ /* ignore */
651
+ }
652
+ }
653
+ /** Abort an in-flight chat query for the given session. */
654
+ handleChatAbort(sessionId, res) {
655
+ if (!this.chatManager) {
656
+ this.json(res, 503, { error: "chat_disabled" });
657
+ return;
658
+ }
659
+ this.chatManager.abort(sessionId);
660
+ this.json(res, 200, { sessionId, aborted: true });
661
+ }
662
+ /**
663
+ * v2.5.0+ REQ-3: task-aware chat endpoint.
664
+ *
665
+ * Resolves the task by id (from the collector session OR the
666
+ * operations cache), builds an additionInfo block from the task
667
+ * context, wraps the user's message, and forwards it to the same
668
+ * ChatProcessManager REQ-2 already plumbs. The sessionId is the
669
+ * deterministic `task-${taskId}` so reloads land on the same
670
+ * SQLite-backed conversation.
671
+ *
672
+ * Returns 404 when the task is unknown — we deliberately do NOT
673
+ * answer off-topic questions for tasks that don't exist (which is
674
+ * the same behaviour REQ-2 already shows for unknown chat
675
+ * sessions, just gated by a task id instead of a random uuid).
676
+ */
677
+ async handleTaskChatMessage(taskId, req, res) {
678
+ if (!this.chatManager) {
679
+ this.json(res, 503, { error: "chat_disabled" });
680
+ return;
681
+ }
682
+ const method = (req.method ?? "GET").toUpperCase();
683
+ if (method !== "POST" && method !== "GET") {
684
+ this.text(res, 405, "Method Not Allowed");
685
+ return;
686
+ }
687
+ let message = "";
688
+ if (method === "POST") {
689
+ const body = await readJsonBody(req);
690
+ if (body && typeof body === "object" && typeof body.message === "string") {
691
+ message = body.message;
692
+ }
693
+ }
694
+ if (typeof message !== "string" || message.length === 0) {
695
+ this.json(res, 400, { error: "bad_request", message: "Missing `message` field in JSON body." });
696
+ return;
697
+ }
698
+ // Resolve the task context. Collector sessions are the primary
699
+ // source for in-progress tasks (live `title` / `status` /
700
+ // `context.worktree`); the operations cache is the fallback for
701
+ // tasks the user is asking about retroactively (session is gone
702
+ // but the CLI still has the description / operations). If neither
703
+ // knows the task, return 404 — answering without context would
704
+ // produce a misleading answer.
705
+ const ctx = await this.resolveTaskChatContext(taskId);
706
+ if (!ctx) {
707
+ this.json(res, 404, { error: "not_found", id: taskId });
708
+ return;
709
+ }
710
+ const builder = new TaskChatContextBuilder(ctx);
711
+ const sessionId = builder.sessionId();
712
+ const wrapped = builder.wrapMessage(message);
713
+ // SSE headers (mirror /api/chat/:id/message so the frontend SSE
714
+ // parser can stay identical).
715
+ res.statusCode = 200;
716
+ res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
717
+ res.setHeader("Cache-Control", "no-cache, no-transform");
718
+ res.setHeader("Connection", "keep-alive");
719
+ res.setHeader("X-Accel-Buffering", "no");
720
+ res.flushHeaders?.();
721
+ let chunkIndex = 0;
722
+ const sendFrame = (event, payload) => {
723
+ if (res.writableEnded || res.destroyed)
724
+ return;
725
+ const id = ++chunkIndex;
726
+ try {
727
+ res.write(`id: ${id}\nevent: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
728
+ }
729
+ catch {
730
+ /* socket closed mid-write */
731
+ }
732
+ };
733
+ // Heartbeat so the client knows the connection is live.
734
+ sendFrame("ready", { sessionId, taskId, truncated: builder.truncated });
735
+ try {
736
+ const result = await this.chatManager.sendStream(sessionId, wrapped, (chunk) => {
737
+ sendFrame("chunk", chunk);
738
+ if (chunk.type === "done" || chunk.type === "error") {
739
+ sendFrame(chunk.type, chunk);
740
+ }
741
+ });
742
+ sendFrame("done", {
743
+ sessionId,
744
+ taskId,
745
+ exitCode: result.exitCode,
746
+ durationMs: result.durationMs,
747
+ });
748
+ }
749
+ catch (err) {
750
+ const msg = err instanceof Error ? err.message : String(err);
751
+ sendFrame("error", { sessionId, taskId, error: { message: msg } });
752
+ }
753
+ try {
754
+ res.end();
755
+ }
756
+ catch {
757
+ /* ignore */
758
+ }
759
+ }
760
+ /**
761
+ * Resolve the task context for a chat turn. Returns the
762
+ * TaskChatContextBuilder input or null when the task is unknown.
763
+ * Prefers the live collector session (rich `context.worktree` +
764
+ * current status); falls back to the operations cache envelope
765
+ * for tasks that have already been finalized.
766
+ */
767
+ async resolveTaskChatContext(taskId) {
768
+ const live = this.collector.getSession(taskId);
769
+ if (live) {
770
+ // Best-effort: pull description / priority / operations from
771
+ // the operations cache if available (the CLI is the canonical
772
+ // source for those — collector only tracks tool calls).
773
+ let description;
774
+ let priority = live.status; // sensible fallback
775
+ let recentOperations = [];
776
+ if (this.operationsCache) {
777
+ try {
778
+ const cached = await this.operationsCache.get(taskId, { allowStale: true });
779
+ description = cached.task.context && typeof cached.task.context === "object"
780
+ ? JSON.stringify(cached.task.context)
781
+ : undefined;
782
+ if (cached.task.goals_and_expected_deliverables && !description) {
783
+ description = cached.task.goals_and_expected_deliverables;
784
+ }
785
+ priority = cached.task.priority ?? priority;
786
+ recentOperations = cached.operations.slice(0, 10).map((op) => ({
787
+ id: op.id,
788
+ sequence: op.sequence,
789
+ milestoneType: op.milestoneType,
790
+ title: op.title,
791
+ description: op.description,
792
+ processDescription: op.processDescription,
793
+ timestamp: op.timestamp,
794
+ sessionShort: op.sessionShort,
795
+ }));
796
+ }
797
+ catch {
798
+ /* fall through with defaults — we still answer */
799
+ }
800
+ }
801
+ return {
802
+ taskId: live.taskId,
803
+ title: live.title,
804
+ description,
805
+ status: live.status,
806
+ priority,
807
+ worktree: live.context?.worktree,
808
+ recentOperations,
809
+ };
810
+ }
811
+ if (this.operationsCache) {
812
+ try {
813
+ const cached = await this.operationsCache.get(taskId, { allowStale: true });
814
+ const ctx = cached.task.context;
815
+ return {
816
+ taskId: cached.task.id,
817
+ title: cached.task.title,
818
+ description: ctx && typeof ctx === "object" ? JSON.stringify(ctx) : cached.task.goals_and_expected_deliverables,
819
+ status: cached.task.status,
820
+ priority: cached.task.priority,
821
+ worktree: cached.task.projectPath,
822
+ recentOperations: cached.operations.slice(0, 10).map((op) => ({
823
+ id: op.id,
824
+ sequence: op.sequence,
825
+ milestoneType: op.milestoneType,
826
+ title: op.title,
827
+ description: op.description,
828
+ processDescription: op.processDescription,
829
+ timestamp: op.timestamp,
830
+ sessionShort: op.sessionShort,
831
+ })),
832
+ };
833
+ }
834
+ catch {
835
+ return null;
836
+ }
837
+ }
838
+ return null;
839
+ }
464
840
  handleFileContent(url, req, res) {
465
841
  const wantsHtml = prefersHtml(req);
466
842
  const raw = url.searchParams.get("path") ?? "";
@@ -1190,6 +1566,8 @@ export function renderTaskPage(session) {
1190
1566
  <pre class="json">${htmlEscape(JSON.stringify(session, null, 2))}</pre>
1191
1567
  </details>
1192
1568
  </section>
1569
+
1570
+ ${renderChatPanel({ scope: "task", taskId: session.taskId })}
1193
1571
  </div>
1194
1572
  </div>
1195
1573
 
@@ -1198,6 +1576,7 @@ export function renderTaskPage(session) {
1198
1576
  <script src="/static/task-operations.js"></script>
1199
1577
  <script src="/static/file-tree.js"></script>
1200
1578
  <script src="/static/tool-call-detail.js"></script>
1579
+ <script src="/static/chat-panel.js" defer></script>
1201
1580
  <script>
1202
1581
  if (window.mermaid) {
1203
1582
  window.mermaid.initialize({
@@ -1337,6 +1716,18 @@ function walkTreeFlat(node, depth, out, affectedSet) {
1337
1716
  walkTreeFlat(child, depth + 1, out, affectedSet);
1338
1717
  }
1339
1718
  }
1719
+ // v2.5.1+ REQ-2 fix: `renderChatPanel` now lives in `./chat-panel-html.ts`
1720
+ // so BOTH `renderIndexPage` (legacy home, server.ts) and
1721
+ // `_renderSessionForestPageImpl` (production home, task-session-store.ts)
1722
+ // can embed the same markup. See chat-panel-html.ts for the asset
1723
+ // contract that home-chat-v25.test.ts and
1724
+ // chat-panel-session-forest-v251.test.ts pin.
1725
+ //
1726
+ // v2.5 REQ-3: `renderChatPanel()` accepts `{scope, taskId}` so the
1727
+ // task-detail page can embed a task-aware panel (with
1728
+ // `data-chat-scope="task" data-task-id="N"` hooks the JS factory reads
1729
+ // to route to /api/task-chat/<N>/message). The contract is pinned by
1730
+ // test/task-chat-v25.test.ts.
1340
1731
  function renderIndexPage(sessions) {
1341
1732
  // SSR hint — the tree container is always rendered; if /api/tasks/tree
1342
1733
  // returns 503 the JS shows a graceful empty state with a hint to
@@ -1361,6 +1752,8 @@ function renderIndexPage(sessions) {
1361
1752
  </p>
1362
1753
  </header>
1363
1754
 
1755
+ ${renderChatPanel()}
1756
+
1364
1757
  <section class="panel tree-controls">
1365
1758
  <div class="tree-controls-row">
1366
1759
  <label class="tree-search">
@@ -1449,6 +1842,17 @@ function renderIndexPage(sessions) {
1449
1842
  <script src="/static/app.js"></script>
1450
1843
  <script src="/static/mermaid-renderer.js"></script>
1451
1844
  <script src="/static/tasks-tree.js"></script>
1845
+ <!--
1846
+ v2.5.x REQ-4: vendor markdown rendering for the home chat panel.
1847
+ Load order matters - marked must register globals before DOMPurify
1848
+ is constructed, and both must register before markdown-renderer.js
1849
+ resolves them at import time. chat-panel.js only depends on
1850
+ window.markdownRenderer, which is set by markdown-renderer.js.
1851
+ -->
1852
+ <script src="/static/marked.min.js" defer></script>
1853
+ <script src="/static/dompurify.min.js" defer></script>
1854
+ <script src="/static/markdown-renderer.js" defer></script>
1855
+ <script src="/static/chat-panel.js" defer></script>
1452
1856
  </body>
1453
1857
  </html>`;
1454
1858
  }