@keyou007/agent-bot 0.1.24-alpha.2 → 0.1.24-alpha.4

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 (100) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/README.md +33 -5
  3. package/README.zh.md +32 -5
  4. package/dist/acp/AcpJsonRpcConnection.js +3 -1
  5. package/dist/acp/AcpJsonRpcConnection.js.map +1 -1
  6. package/dist/acp/AcpSessionManager.js +0 -14
  7. package/dist/acp/AcpSessionManager.js.map +1 -1
  8. package/dist/acp/acpTypes.js.map +1 -1
  9. package/dist/cli/SelfUpdateRunner.js.map +1 -1
  10. package/dist/cli/SelfUpdater.js +2 -0
  11. package/dist/cli/SelfUpdater.js.map +1 -1
  12. package/dist/codex/AppServerConnection.js +3 -1
  13. package/dist/codex/AppServerConnection.js.map +1 -1
  14. package/dist/codex/CodexEventMapper.js +28 -1
  15. package/dist/codex/CodexEventMapper.js.map +1 -1
  16. package/dist/codex/CodexRuntime.js +92 -0
  17. package/dist/codex/CodexRuntime.js.map +1 -1
  18. package/dist/commands/CommandRouter.js +5 -3
  19. package/dist/commands/CommandRouter.js.map +1 -1
  20. package/dist/feishu/CardRenderer.js +45 -23
  21. package/dist/feishu/CardRenderer.js.map +1 -1
  22. package/dist/feishu/FeishuConnector.js +2 -0
  23. package/dist/feishu/FeishuConnector.js.map +1 -1
  24. package/dist/feishu/FeishuMessageClient.js +44 -0
  25. package/dist/feishu/FeishuMessageClient.js.map +1 -1
  26. package/dist/feishu/FeishuTurnPresenter.js +114 -37
  27. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  28. package/dist/feishu/RecentMessageContext.js +70 -0
  29. package/dist/feishu/RecentMessageContext.js.map +1 -0
  30. package/dist/index.js +62 -24
  31. package/dist/index.js.map +1 -1
  32. package/dist/local-files/DiagramPreview.js +171 -0
  33. package/dist/local-files/DiagramPreview.js.map +1 -0
  34. package/dist/local-files/LocalFileViewerServer.js +141 -3
  35. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  36. package/dist/local-files/PreviewSyntaxHighlight.js +57 -0
  37. package/dist/local-files/PreviewSyntaxHighlight.js.map +1 -0
  38. package/dist/local-files/TurnPreviewPage.js +497 -62
  39. package/dist/local-files/TurnPreviewPage.js.map +1 -1
  40. package/dist/presentation/OutboundRouter.js +18 -9
  41. package/dist/presentation/OutboundRouter.js.map +1 -1
  42. package/dist/presentation/TurnStateReducer.js +155 -6
  43. package/dist/presentation/TurnStateReducer.js.map +1 -1
  44. package/dist/presentation/turnReasoning.js +23 -0
  45. package/dist/presentation/turnReasoning.js.map +1 -0
  46. package/dist/proxy/ProxySessionController.js +466 -38
  47. package/dist/proxy/ProxySessionController.js.map +1 -1
  48. package/dist/state/StateStore.js +43 -2
  49. package/dist/state/StateStore.js.map +1 -1
  50. package/dist/state/TurnPreviewJournal.js +469 -0
  51. package/dist/state/TurnPreviewJournal.js.map +1 -0
  52. package/dist/state/migrations.js +10 -0
  53. package/dist/state/migrations.js.map +1 -1
  54. package/dist/updates/AutomaticUpdatePreparer.js +16 -7
  55. package/dist/updates/AutomaticUpdatePreparer.js.map +1 -1
  56. package/dist/updates/ManualUpdateController.js +102 -0
  57. package/dist/updates/ManualUpdateController.js.map +1 -0
  58. package/dist/updates/PublishedRelease.js +42 -4
  59. package/dist/updates/PublishedRelease.js.map +1 -1
  60. package/dist/utils/ProtocolLineWarning.js +15 -0
  61. package/dist/utils/ProtocolLineWarning.js.map +1 -0
  62. package/docs/technical-reference.md +21 -1
  63. package/docs/technical-reference.zh.md +21 -1
  64. package/npm-shrinkwrap.json +1401 -10
  65. package/package.json +3 -1
  66. package/skills/agent-bot/SKILL.md +12 -1
  67. package/src/acp/AcpJsonRpcConnection.ts +3 -1
  68. package/src/acp/AcpSessionManager.ts +0 -17
  69. package/src/acp/acpTypes.ts +0 -1
  70. package/src/cli/SelfUpdateRunner.ts +1 -0
  71. package/src/cli/SelfUpdater.ts +2 -0
  72. package/src/codex/AppServerConnection.ts +3 -1
  73. package/src/codex/CodexEventMapper.ts +29 -1
  74. package/src/codex/CodexRuntime.ts +87 -0
  75. package/src/commands/CommandRouter.ts +4 -3
  76. package/src/commands/commandTypes.ts +1 -0
  77. package/src/feishu/CardRenderer.ts +43 -21
  78. package/src/feishu/FeishuConnector.ts +2 -0
  79. package/src/feishu/FeishuMessageClient.ts +44 -0
  80. package/src/feishu/FeishuTurnPresenter.ts +107 -31
  81. package/src/feishu/RecentMessageContext.ts +90 -0
  82. package/src/feishu/types.ts +5 -0
  83. package/src/index.ts +62 -22
  84. package/src/local-files/DiagramPreview.ts +173 -0
  85. package/src/local-files/LocalFileViewerServer.ts +129 -2
  86. package/src/local-files/PreviewSyntaxHighlight.ts +58 -0
  87. package/src/local-files/TurnPreviewPage.ts +537 -59
  88. package/src/presentation/OutboundRouter.ts +25 -7
  89. package/src/presentation/TurnStateReducer.ts +151 -7
  90. package/src/presentation/turnReasoning.ts +21 -0
  91. package/src/presentation/turnViewTypes.ts +19 -1
  92. package/src/proxy/ProxySessionController.ts +457 -27
  93. package/src/runtime/types.ts +21 -0
  94. package/src/state/StateStore.ts +45 -2
  95. package/src/state/TurnPreviewJournal.ts +398 -0
  96. package/src/state/migrations.ts +10 -0
  97. package/src/updates/AutomaticUpdatePreparer.ts +21 -7
  98. package/src/updates/ManualUpdateController.ts +116 -0
  99. package/src/updates/PublishedRelease.ts +49 -3
  100. package/src/utils/ProtocolLineWarning.ts +13 -0
@@ -0,0 +1,173 @@
1
+ import type MarkdownIt from "markdown-it";
2
+
3
+ export function enableDiagramFences(markdown: InstanceType<typeof MarkdownIt>): void {
4
+ const original = markdown.renderer.rules.fence!;
5
+ markdown.renderer.rules.fence = (tokens, index, options, environment, renderer) => {
6
+ const token = tokens[index]!;
7
+ if (!/^(mermaid|flowchart)$/iu.test(token.info.trim())) return original(tokens, index, options, environment, renderer);
8
+ const zh = environment?.language !== "en";
9
+ const source = markdown.utils.escapeHtml(token.content);
10
+ return `<div class="diagram" data-diagram data-diagram-size="fit" data-preview-key="diagram:${index}">
11
+ <div class="diagram-toolbar" role="group" aria-label="${zh ? "图表显示模式" : "Diagram display mode"}">
12
+ <button type="button" data-diagram-mode="preview" aria-pressed="true">${zh ? "预览" : "Preview"}</button>
13
+ <button type="button" data-diagram-mode="source" aria-pressed="false">${zh ? "源码" : "Source"}</button>
14
+ <button type="button" data-diagram-size-toggle hidden>${zh ? "原大" : "Actual size"}</button>
15
+ </div>
16
+ <p class="diagram-notice" role="status">${zh ? "正在渲染图表…" : "Rendering diagram…"}</p>
17
+ <div class="diagram-preview" role="img" aria-label="${zh ? "Mermaid 图表" : "Mermaid diagram"}"></div>
18
+ <pre class="diagram-source" hidden><code>${source}</code></pre>
19
+ <noscript><pre><code>${source}</code></pre></noscript>
20
+ </div>`;
21
+ };
22
+ }
23
+
24
+ export const DIAGRAM_PREVIEW_CSS = `
25
+ .diagram { --diagram-max-height:min(640px, 70vh); border:1px solid var(--border, #d8dee8); border-radius:12px; overflow:hidden; margin:12px 0; }
26
+ .diagram-toolbar { display:flex; flex-wrap:wrap; gap:4px; padding:6px 8px; border-bottom:1px solid var(--border, #d8dee8); }
27
+ .diagram-toolbar button { font:inherit; font-size:12px; color:inherit; background:transparent; border:1px solid transparent; border-radius:6px; padding:3px 10px; cursor:pointer; }
28
+ .diagram-toolbar button[aria-pressed="true"] { border-color:currentColor; }
29
+ .diagram-toolbar [data-diagram-size-toggle] { margin-left:auto; }
30
+ .diagram-toolbar button:focus-visible { outline:2px solid #4285d4; outline-offset:1px; }
31
+ .diagram-notice { font-size:12px; opacity:.75; margin:8px 12px!important; }
32
+ .diagram-preview { padding:12px; overflow:auto; max-height:var(--diagram-max-height); }
33
+ .diagram-preview svg { display:block; margin:auto; max-width:none!important; height:auto; width:min(100%, var(--diagram-width, 100%))!important; }
34
+ .diagram[data-diagram-size="actual"] .diagram-preview svg { width:var(--diagram-width, 100%)!important; }
35
+ @media (max-width:640px), (pointer:coarse) {
36
+ .diagram { --diagram-max-height:min(480px, 55vh); }
37
+ @supports (height:1svh) { .diagram { --diagram-max-height:min(480px, 55svh); } }
38
+ }
39
+ .diagram .diagram-source { margin:0; border:0; border-radius:0; max-height:30em; overflow:auto; white-space:pre; }
40
+ .diagram [hidden] { display:none!important; }
41
+ @media (hover:none), (pointer:coarse) { .diagram-preview, .diagram-source { scrollbar-width:none; } .diagram-preview::-webkit-scrollbar, .diagram-source::-webkit-scrollbar { display:none; } }
42
+ `;
43
+
44
+ export const DIAGRAM_PREVIEW_CLIENT_SCRIPT = String.raw`(() => {
45
+ const zh = /^zh/i.test(document.documentElement.lang);
46
+ const loading = zh ? "正在渲染图表…" : "Rendering diagram…";
47
+ const failed = zh ? "图表暂时无法渲染,已显示源码。" : "Diagram could not be rendered. Showing source.";
48
+ const states = new WeakMap();
49
+ let library;
50
+ let sequence = 0;
51
+ const loadLibrary = () => {
52
+ if (!library) library = new Promise((resolve, reject) => {
53
+ const url = document.body.dataset.diagramScript;
54
+ if (!url) { reject(new Error("Diagram renderer unavailable")); return; }
55
+ const script = document.createElement("script");
56
+ script.src = url;
57
+ const timeout = setTimeout(() => {
58
+ script.remove();
59
+ reject(new Error("Diagram renderer timed out"));
60
+ }, 15000);
61
+ script.onload = () => {
62
+ clearTimeout(timeout);
63
+ const mermaid = window.mermaid;
64
+ if (!mermaid) { reject(new Error("Diagram renderer unavailable")); return; }
65
+ try {
66
+ mermaid.initialize({
67
+ startOnLoad: false, securityLevel: "strict", suppressErrorRendering: true,
68
+ maxTextSize: 50000, maxEdges: 500, htmlLabels: false,
69
+ flowchart: { htmlLabels: false },
70
+ theme: window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "default",
71
+ secure: ["securityLevel", "startOnLoad", "maxTextSize", "maxEdges", "suppressErrorRendering", "htmlLabels", "flowchart", "secure"],
72
+ });
73
+ resolve(mermaid);
74
+ } catch (error) { reject(error); }
75
+ };
76
+ script.onerror = () => { clearTimeout(timeout); script.remove(); reject(new Error("Diagram renderer unavailable")); };
77
+ document.head.appendChild(script);
78
+ }).catch((error) => { library = undefined; throw error; });
79
+ return library;
80
+ };
81
+ const show = (block, state) => {
82
+ const mode = state.error ? "source" : state.mode;
83
+ block.querySelector(".diagram-preview").hidden = mode !== "preview";
84
+ block.querySelector(".diagram-source").hidden = mode !== "source";
85
+ block.dataset.diagramSize = state.size;
86
+ const sizeButton = block.querySelector("[data-diagram-size-toggle]");
87
+ sizeButton.hidden = mode !== "preview" || state.rendered !== state.source;
88
+ sizeButton.textContent = state.size === "actual" ? (zh ? "适应" : "Fit") : (zh ? "原大" : "Actual size");
89
+ sizeButton.setAttribute("aria-label", state.size === "actual"
90
+ ? (zh ? "适应容器宽度" : "Fit diagram to width")
91
+ : (zh ? "按原始尺寸查看图表" : "View diagram at actual size"));
92
+ const notice = block.querySelector(".diagram-notice");
93
+ notice.hidden = !state.error && (state.rendered === state.source || mode === "source");
94
+ notice.textContent = state.error ? failed : loading;
95
+ block.querySelectorAll("[data-diagram-mode]").forEach((button) => {
96
+ button.setAttribute("aria-pressed", String(button.dataset.diagramMode === mode));
97
+ });
98
+ };
99
+ const render = async (block, state, revision) => {
100
+ const source = state.source;
101
+ let container;
102
+ try {
103
+ // Keep model-authored Mermaid config from weakening the renderer's safety policy.
104
+ if (source.length > 50000 || /^\s*---(?:\r?\n|$)/u.test(source) || /%%\s*\{/u.test(source)) {
105
+ throw new Error("Diagram configuration or size is unsupported");
106
+ }
107
+ const mermaid = await loadLibrary();
108
+ if (!block.isConnected || state.revision !== revision) return;
109
+ container = document.createElement("div");
110
+ container.style.cssText = "position:absolute;left:-100000px;top:0;width:1000px;visibility:hidden;pointer-events:none";
111
+ document.body.appendChild(container);
112
+ const result = await mermaid.render("agentbot-diagram-" + (++sequence), source, container);
113
+ if (!block.isConnected || state.revision !== revision) return;
114
+ const template = document.createElement("template");
115
+ template.innerHTML = result.svg;
116
+ // Mermaid's strict renderer sanitizes SVG. Also prohibit links, images and callbacks.
117
+ template.content.querySelectorAll("script,foreignObject,iframe,object,embed,image").forEach((node) => node.remove());
118
+ template.content.querySelectorAll("*").forEach((node) => {
119
+ for (const attr of Array.from(node.attributes)) {
120
+ if (/^on/i.test(attr.name) || /^(href|xlink:href|src)$/i.test(attr.name) && !attr.value.startsWith("#")) node.removeAttribute(attr.name);
121
+ }
122
+ });
123
+ const svg = template.content.querySelector("svg");
124
+ if (!svg) throw new Error("Missing SVG");
125
+ const viewBox = (svg.getAttribute("viewBox") || "").split(/[ ,]+/).map(Number);
126
+ if (viewBox.length === 4 && viewBox.every(Number.isFinite) && viewBox[2] > 0 && viewBox[3] > 0) {
127
+ svg.style.setProperty("--diagram-width", viewBox[2] + "px");
128
+ }
129
+ block.querySelector(".diagram-preview").replaceChildren(template.content);
130
+ state.rendered = source;
131
+ state.error = false;
132
+ show(block, state);
133
+ } catch {
134
+ if (block.isConnected && state.revision === revision) { state.error = true; show(block, state); }
135
+ } finally { if (container) container.remove(); }
136
+ };
137
+ const schedule = (block, state) => {
138
+ clearTimeout(state.timer);
139
+ state.revision += 1;
140
+ if (state.mode !== "preview" || state.rendered === state.source) return;
141
+ const revision = state.revision;
142
+ state.timer = setTimeout(() => void render(block, state, revision), 180);
143
+ };
144
+ const refresh = (block) => {
145
+ const source = block.querySelector(".diagram-source code").textContent;
146
+ let state = states.get(block);
147
+ if (!state) {
148
+ state = { source, mode: "preview", size: "fit", revision: 0, error: false };
149
+ states.set(block, state);
150
+ block.querySelector("[data-diagram-size-toggle]").addEventListener("click", () => {
151
+ state.size = state.size === "actual" ? "fit" : "actual";
152
+ const preview = block.querySelector(".diagram-preview");
153
+ preview.scrollLeft = 0;
154
+ preview.scrollTop = 0;
155
+ show(block, state);
156
+ });
157
+ block.querySelectorAll("[data-diagram-mode]").forEach((button) => {
158
+ button.addEventListener("click", () => {
159
+ state.mode = button.dataset.diagramMode;
160
+ if (state.mode === "preview") state.error = false;
161
+ show(block, state);
162
+ schedule(block, state);
163
+ });
164
+ });
165
+ } else if (state.source === source) { return; }
166
+ else { state.source = source; state.error = false; }
167
+ show(block, state);
168
+ schedule(block, state);
169
+ };
170
+ const scan = () => document.querySelectorAll("[data-diagram]").forEach(refresh);
171
+ window.agentBotDiagrams = { refresh, scan };
172
+ scan();
173
+ })();`;
@@ -1,5 +1,7 @@
1
+ import { TurnPreviewProjection, type TurnPreviewJournal } from "../state/TurnPreviewJournal.js";
1
2
  import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
3
  import fs from "node:fs";
4
+ import { createRequire } from "node:module";
3
5
  import http, { type IncomingMessage, type ServerResponse } from "node:http";
4
6
  import path from "node:path";
5
7
  import hljs from "highlight.js/lib/common";
@@ -16,10 +18,15 @@ import {
16
18
  isTurnPreviewState,
17
19
  detectTurnPreviewLanguage,
18
20
  renderTurnPreviewPage,
21
+ renderTurnPreviewPatch,
22
+ renderToolFooter,
23
+ renderTurnPreviewDetail,
19
24
  renderTurnPreviewSnapshot,
20
25
  TURN_PREVIEW_CLIENT_SCRIPT,
21
26
  } from "./TurnPreviewPage.js";
22
27
 
28
+ const require = createRequire(import.meta.url);
29
+ let mermaidSource: string | undefined;
23
30
  const SECRET_FILE = "secret";
24
31
  const PORT_FILE = "port";
25
32
  const SHORT_TOKEN_BYTES = 12;
@@ -230,7 +237,9 @@ export interface LocalFileViewerServerOptions {
230
237
  port: number;
231
238
  publicBaseUrl?: string;
232
239
  stateDirectory: string;
240
+ previewJournal?: TurnPreviewJournal;
233
241
  getTurnSnapshot?: (turnId: string) => unknown;
242
+ loadTurnSnapshot?: (turnId: string) => Promise<unknown>;
234
243
  turnPreviewPollIntervalMs?: number;
235
244
  }
236
245
 
@@ -376,6 +385,11 @@ export class LocalFileViewerServer {
376
385
  this.sendJavascript(response, request.method === "HEAD", TURN_PREVIEW_CLIENT_SCRIPT);
377
386
  return;
378
387
  }
388
+ if (route === "/assets/mermaid.js") {
389
+ mermaidSource ??= fs.readFileSync(require.resolve("mermaid/dist/mermaid.min.js"), "utf8");
390
+ this.sendJavascript(response, request.method === "HEAD", mermaidSource);
391
+ return;
392
+ }
379
393
  const turnPreviewMatch = new RegExp(`^/turn-preview/([A-Za-z0-9_-]{${SHORT_TOKEN_LENGTH}})$`, "u").exec(route);
380
394
  if (turnPreviewMatch) {
381
395
  const turnId = this.verifyTurnPreviewToken(
@@ -386,23 +400,68 @@ export class LocalFileViewerServer {
386
400
  this.sendHtml(response, 403, errorPage("Turn 链接无效", "签名校验失败,Agent Bot 已拒绝该请求。"), request.method === "HEAD");
387
401
  return;
388
402
  }
389
- const snapshot = this.options.getTurnSnapshot?.(turnId);
403
+ const journal = this.options.previewJournal;
404
+ const recorded = journal?.has(turnId);
405
+ if (recorded && requestUrl.searchParams.get("events") === "1") {
406
+ this.serveJournalEvents(request, response, turnId, journal!);
407
+ return;
408
+ }
409
+ const detailKey = requestUrl.searchParams.get("detail");
410
+ let snapshot = recorded
411
+ ? journal!.load(turnId, detailKey ?? false)
412
+ : this.options.getTurnSnapshot?.(turnId);
413
+ if (!recorded && this.options.loadTurnSnapshot && request.method !== "HEAD"
414
+ && requestUrl.searchParams.get("events") !== "1" && !requestUrl.searchParams.has("detail")) {
415
+ const saved = snapshot;
416
+ try {
417
+ snapshot = await this.options.loadTurnSnapshot(turnId);
418
+ } catch (error) {
419
+ const message = error instanceof Error ? error.message : String(error);
420
+ if (!isTurnPreviewState(saved)) {
421
+ this.sendHtml(response, 502, errorPage("历史执行详情读取失败", `${message} 刷新页面重试;任务状态未改变。`), false);
422
+ return;
423
+ }
424
+ snapshot = { ...saved, historyDetailError: message };
425
+ }
426
+ }
390
427
  if (!isTurnPreviewState(snapshot)) {
391
428
  this.sendHtml(response, 404, errorPage("Turn 不存在", "这次执行可能尚未保存或已被清理。"), request.method === "HEAD");
392
429
  return;
393
430
  }
431
+ if (requestUrl.searchParams.has("detail")) {
432
+ const language = detectTurnPreviewLanguage(request.headers["accept-language"]);
433
+ const key = requestUrl.searchParams.get("detail") ?? "";
434
+ const tool = snapshot.activities.find((a) => a.kind === "tool" && `tool:${a.id}` === key);
435
+ const after = requestUrl.searchParams.get("detailAfter");
436
+ const outputAppend = recorded && after && tool?.kind === "tool" && tool.tool.kind === "command" && !tool.tool.error
437
+ && !/repl/i.test(tool.tool.title) && snapshot.previewCursor !== undefined
438
+ ? journal!.outputAppend(turnId, tool.id, Number(after), snapshot.previewCursor) : undefined;
439
+ const detail = outputAppend !== undefined && tool?.kind === "tool"
440
+ && (snapshot.fullToolOutputs?.[tool.id]?.length ?? 0) > outputAppend.length
441
+ ? { key, revision: String(tool.tool.previewRevision), content: "", outputAppend,
442
+ footer: renderToolFooter(tool.tool, snapshot.fullToolOutputs?.[tool.id]?.length ?? 0, language) }
443
+ : renderTurnPreviewDetail(snapshot, key, (filePath) => this.createFileUrl(filePath), language);
444
+ setSecurityHeaders(response);
445
+ response.statusCode = detail ? 200 : 404;
446
+ response.setHeader("Cache-Control", "no-store");
447
+ response.setHeader("Content-Type", "application/json; charset=utf-8");
448
+ response.end(request.method === "HEAD" ? undefined : JSON.stringify(detail ? { ...detail, cursor: snapshot.previewCursor } : { error: "Turn detail not found." }));
449
+ return;
450
+ }
394
451
  if (requestUrl.searchParams.get("events") === "1") {
395
452
  this.serveTurnEvents(request, response, turnId);
396
453
  return;
397
454
  }
398
455
  const eventsUrl = new URL(this.createTurnPreviewUrl(turnId)!);
399
456
  eventsUrl.searchParams.set("events", "1");
457
+ if (snapshot.previewCursor !== undefined) eventsUrl.searchParams.set("after", String(snapshot.previewCursor));
400
458
  const language = detectTurnPreviewLanguage(request.headers["accept-language"]);
401
459
  const html = renderTurnPreviewPage({
402
460
  state: snapshot,
403
461
  eventsUrl: eventsUrl.toString(),
404
462
  scriptPath: `${this.basePath}/assets/turn-preview.js`.replace(/\/{2,}/gu, "/"),
405
463
  localFileUrl: (filePath) => this.createFileUrl(filePath),
464
+ diagramScriptPath: `${this.basePath}/assets/mermaid.js`.replace(/\/{2,}/gu, "/"),
406
465
  language,
407
466
  });
408
467
  this.sendHtml(response, 200, html, request.method === "HEAD");
@@ -683,6 +742,74 @@ export class LocalFileViewerServer {
683
742
  response.once("close", cleanup);
684
743
  }
685
744
 
745
+ private serveJournalEvents(request: IncomingMessage, response: ServerResponse, turnId: string, journal: TurnPreviewJournal): void {
746
+ journal.flush(turnId);
747
+ setSecurityHeaders(response);
748
+ response.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache, no-store", "X-Accel-Buffering": "no" });
749
+ if (request.method === "HEAD") { response.end(); return; }
750
+ response.flushHeaders();
751
+ const language = detectTurnPreviewLanguage(request.headers["accept-language"]);
752
+ const projection = new TurnPreviewProjection(false);
753
+ let offset = 0;
754
+ let closed = false;
755
+ let initial = true;
756
+ const cursor = request.headers["last-event-id"] ?? new URL(request.url!, "http://localhost").searchParams.get("after");
757
+ if (typeof cursor === "string" && /^\d+$/u.test(cursor) && Number.isSafeInteger(Number(cursor)) && Number(cursor) > 0
758
+ && Number(cursor) <= fs.statSync(journal.file(turnId)).size) {
759
+ while (offset < Number(cursor)) {
760
+ const batch = journal.read(turnId, offset, 256 * 1024, Number(cursor));
761
+ for (const record of batch.records) projection.apply(record, ++projection.revision);
762
+ if (batch.offset === offset) break;
763
+ offset = batch.offset;
764
+ }
765
+ initial = offset !== Number(cursor);
766
+ }
767
+ let waitingDrain = false;
768
+ const poll = () => {
769
+ if (closed || waitingDrain) return;
770
+ try {
771
+ projection.changed.clear();
772
+ projection.removed.clear();
773
+ const batch = journal.read(turnId, offset);
774
+ if (!batch.records.length) {
775
+ const state = projection.state;
776
+ if (state && ["completed", "cancelled", "failed"].includes(state.status)) {
777
+ const update = renderTurnPreviewPatch(state, new Set(), new Set(), (file) => this.createFileUrl(file), language);
778
+ response.write(`id: ${offset}\nevent: patch\ndata: ${JSON.stringify(update)}\n\n`);
779
+ cleanup(); response.end();
780
+ }
781
+ return;
782
+ }
783
+ offset = batch.offset;
784
+ for (const record of batch.records) projection.apply(record, ++projection.revision);
785
+ const state = projection.state;
786
+ if (!state) return;
787
+ const render = initial
788
+ ? renderTurnPreviewSnapshot(state, (file) => this.createFileUrl(file), language, { deferDetails: true })
789
+ : renderTurnPreviewPatch(state, projection.changed, projection.removed, (file) => this.createFileUrl(file), language);
790
+ // A terminal record can share a batch with later metadata. Drain the captured prefix first.
791
+ render.terminal = render.terminal && batch.end;
792
+ waitingDrain = !response.write(`id: ${offset}\nevent: ${initial ? "update" : "patch"}\ndata: ${JSON.stringify(render)}\n\n`);
793
+ initial = false;
794
+ if (render.terminal) { cleanup(); response.end(); }
795
+ else if (!batch.end) setImmediate(poll);
796
+ } catch (error) {
797
+ writeServerSentEvent(response, "unavailable", JSON.stringify({ message: "Turn preview could not be read." }));
798
+ cleanup();
799
+ response.end();
800
+ }
801
+ };
802
+ const poller = setInterval(poll, Math.max(50, this.options.turnPreviewPollIntervalMs ?? 1_000));
803
+ poller.unref();
804
+ const keepAlive = setInterval(() => { if (!closed && !waitingDrain) response.write(": keep-alive\n\n"); }, 15_000);
805
+ keepAlive.unref();
806
+ const cleanup = () => { closed = true; clearInterval(poller); clearInterval(keepAlive); };
807
+ response.on("drain", () => { waitingDrain = false; poll(); });
808
+ response.once("close", cleanup);
809
+ request.once("aborted", cleanup);
810
+ poll();
811
+ }
812
+
686
813
  private serveTurnEvents(request: IncomingMessage, response: ServerResponse, turnId: string): void {
687
814
  setSecurityHeaders(response);
688
815
  response.statusCode = 200;
@@ -710,7 +837,7 @@ export class LocalFileViewerServer {
710
837
  const serialized = JSON.stringify(state);
711
838
  if (serialized === previousSnapshot) return;
712
839
  previousSnapshot = serialized;
713
- const snapshot = renderTurnPreviewSnapshot(state, (filePath) => this.createFileUrl(filePath), language);
840
+ const snapshot = renderTurnPreviewSnapshot(state, (filePath) => this.createFileUrl(filePath), language, { deferDetails: true });
714
841
  writeServerSentEvent(response, "update", JSON.stringify(snapshot));
715
842
  };
716
843
  sendSnapshot();
@@ -0,0 +1,58 @@
1
+ import hljs from "highlight.js/lib/common";
2
+ import dockerfile from "highlight.js/lib/languages/dockerfile";
3
+ import dos from "highlight.js/lib/languages/dos";
4
+ import powershell from "highlight.js/lib/languages/powershell";
5
+
6
+ hljs.registerLanguage("dockerfile", dockerfile);
7
+ hljs.registerLanguage("dos", dos);
8
+ hljs.registerLanguage("powershell", powershell);
9
+
10
+ const LANGUAGE_ALIASES: Readonly<Record<string, string>> = { zsh: "bash", pwsh: "powershell" };
11
+ export const MAX_PREVIEW_HIGHLIGHT_BYTES = 64 * 1024;
12
+ const MAX_CACHE_BYTES = 1024 * 1024;
13
+ const MAX_CACHE_ENTRIES = 32;
14
+ const cache = new Map<string, { html: string; bytes: number }>();
15
+ let cacheBytes = 0;
16
+
17
+ // Returning an empty string asks MarkdownIt to escape the original code unchanged.
18
+ export function highlightPreviewCode(value: string, language: string): string {
19
+ const name = language.trim().toLowerCase();
20
+ const resolved = Object.hasOwn(LANGUAGE_ALIASES, name) ? LANGUAGE_ALIASES[name]! : name;
21
+ if (!resolved || !hljs.getLanguage(resolved) || Buffer.byteLength(value, "utf8") > MAX_PREVIEW_HIGHLIGHT_BYTES) return "";
22
+ const key = `${resolved}\0${value}`;
23
+ const cached = cache.get(key);
24
+ if (cached) {
25
+ cache.delete(key);
26
+ cache.set(key, cached);
27
+ return cached.html;
28
+ }
29
+ try {
30
+ const html = hljs.highlight(value, { language: resolved, ignoreIllegals: true }).value;
31
+ const bytes = Buffer.byteLength(key, "utf8") + Buffer.byteLength(html, "utf8");
32
+ if (bytes <= MAX_CACHE_BYTES) {
33
+ while (cache.size >= MAX_CACHE_ENTRIES || cacheBytes + bytes > MAX_CACHE_BYTES) {
34
+ const oldest = cache.keys().next().value!;
35
+ cacheBytes -= cache.get(oldest)!.bytes;
36
+ cache.delete(oldest);
37
+ }
38
+ cache.set(key, { html, bytes });
39
+ cacheBytes += bytes;
40
+ }
41
+ return html;
42
+ } catch {
43
+ return "";
44
+ }
45
+ }
46
+
47
+ export const PREVIEW_SYNTAX_CSS = `
48
+ .markdown pre { --syntax-comment:#667080; --syntax-keyword:#9639a5; --syntax-string:#087f5b; --syntax-number:#986801; --syntax-title:#005cc5; --syntax-deletion:#b31d28; }
49
+ .markdown pre .hljs-comment, .markdown pre .hljs-quote { color:var(--syntax-comment); }
50
+ .markdown pre .hljs-keyword, .markdown pre .hljs-selector-tag, .markdown pre .hljs-literal, .markdown pre .hljs-section, .markdown pre .hljs-link { color:var(--syntax-keyword); }
51
+ .markdown pre .hljs-string, .markdown pre .hljs-regexp, .markdown pre .hljs-name, .markdown pre .hljs-type, .markdown pre .hljs-attribute, .markdown pre .hljs-symbol, .markdown pre .hljs-bullet, .markdown pre .hljs-addition, .markdown pre .hljs-variable, .markdown pre .hljs-template-tag, .markdown pre .hljs-template-variable { color:var(--syntax-string); }
52
+ .markdown pre .hljs-number, .markdown pre .hljs-meta, .markdown pre .hljs-built_in, .markdown pre .hljs-builtin-name, .markdown pre .hljs-params, .markdown pre .hljs-attr { color:var(--syntax-number); }
53
+ .markdown pre .hljs-title { color:var(--syntax-title); }
54
+ .markdown pre .hljs-deletion { color:var(--syntax-deletion); }
55
+ @media (prefers-color-scheme:dark) {
56
+ .markdown pre { --syntax-comment:#8b949e; --syntax-keyword:#ff7b72; --syntax-string:#a5d6ff; --syntax-number:#d2a8ff; --syntax-title:#79c0ff; --syntax-deletion:#ffdcd7; }
57
+ }
58
+ `;