@keyou007/agent-bot 0.1.21 → 0.1.23

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 (53) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +16 -2
  3. package/README.zh.md +16 -2
  4. package/config.example.yaml +5 -0
  5. package/dist/config/loadConfig.js +4 -0
  6. package/dist/config/loadConfig.js.map +1 -1
  7. package/dist/config/schema.js +1 -0
  8. package/dist/config/schema.js.map +1 -1
  9. package/dist/feishu/CardRenderer.js +32 -3
  10. package/dist/feishu/CardRenderer.js.map +1 -1
  11. package/dist/feishu/FeishuMessageClient.js +2 -2
  12. package/dist/feishu/FeishuMessageClient.js.map +1 -1
  13. package/dist/index.js +44 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/local-files/LocalFileViewerServer.js +24 -10
  16. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  17. package/dist/local-files/MarkdownFileLinks.js +12 -5
  18. package/dist/local-files/MarkdownFileLinks.js.map +1 -1
  19. package/dist/local-files/TurnPreviewPage.js +133 -87
  20. package/dist/local-files/TurnPreviewPage.js.map +1 -1
  21. package/dist/proxy/ProxySessionController.js +12 -2
  22. package/dist/proxy/ProxySessionController.js.map +1 -1
  23. package/dist/state/StateStore.js +25 -0
  24. package/dist/state/StateStore.js.map +1 -1
  25. package/dist/state/migrations.js +12 -0
  26. package/dist/state/migrations.js.map +1 -1
  27. package/dist/updates/AutomaticUpdatePreparer.js +66 -0
  28. package/dist/updates/AutomaticUpdatePreparer.js.map +1 -0
  29. package/dist/updates/DailyUpdateMonitor.js +261 -0
  30. package/dist/updates/DailyUpdateMonitor.js.map +1 -0
  31. package/dist/updates/PublishedRelease.js +43 -0
  32. package/dist/updates/PublishedRelease.js.map +1 -0
  33. package/dist/updates/types.js +2 -0
  34. package/dist/updates/types.js.map +1 -0
  35. package/npm-shrinkwrap.json +2 -2
  36. package/package.json +1 -1
  37. package/skills/agent-bot/SKILL.md +2 -0
  38. package/src/config/loadConfig.ts +4 -0
  39. package/src/config/schema.ts +4 -1
  40. package/src/feishu/CardRenderer.ts +35 -3
  41. package/src/feishu/FeishuMessageClient.ts +2 -1
  42. package/src/feishu/types.ts +1 -1
  43. package/src/index.ts +40 -0
  44. package/src/local-files/LocalFileViewerServer.ts +23 -11
  45. package/src/local-files/MarkdownFileLinks.ts +12 -7
  46. package/src/local-files/TurnPreviewPage.ts +134 -88
  47. package/src/proxy/ProxySessionController.ts +14 -2
  48. package/src/state/StateStore.ts +31 -0
  49. package/src/state/migrations.ts +12 -0
  50. package/src/updates/AutomaticUpdatePreparer.ts +76 -0
  51. package/src/updates/DailyUpdateMonitor.ts +252 -0
  52. package/src/updates/PublishedRelease.ts +44 -0
  53. package/src/updates/types.ts +19 -0
@@ -2,7 +2,8 @@ import path from "node:path";
2
2
  import MarkdownIt from "markdown-it";
3
3
  import type { ToolState } from "../runtime/types.js";
4
4
  import type { FileSummary, TurnActivity, TurnViewState, TurnViewStatus } from "../presentation/turnViewTypes.js";
5
- import { displayToolCommand, formatShellCommandForDisplay } from "../feishu/CardRenderer.js";
5
+ import { displayFilePath, displayToolCommand, formatShellCommandForDisplay, toolStatusIcon } from "../feishu/CardRenderer.js";
6
+ import { isFileUrl, rewriteMarkdownFileLinks } from "./MarkdownFileLinks.js";
6
7
 
7
8
  const MARKDOWN = new MarkdownIt({
8
9
  breaks: true,
@@ -10,6 +11,8 @@ const MARKDOWN = new MarkdownIt({
10
11
  linkify: true,
11
12
  typographer: false,
12
13
  });
14
+ const validateLink = MARKDOWN.validateLink.bind(MARKDOWN);
15
+ MARKDOWN.validateLink = (url) => isFileUrl(url) || validateLink(url);
13
16
  const defaultLinkOpen = MARKDOWN.renderer.rules.link_open
14
17
  ?? ((tokens, index, options, _environment, renderer) => renderer.renderToken(tokens, index, options));
15
18
  MARKDOWN.renderer.rules.link_open = (tokens, index, options, environment, renderer) => {
@@ -161,7 +164,12 @@ export const TURN_PREVIEW_CLIENT_SCRIPT = `(() => {
161
164
  const duration = hours > 0
162
165
  ? String(hours).padStart(2, "0") + ":" + String(minutes).padStart(2, "0") + ":" + String(remainder).padStart(2, "0")
163
166
  : String(minutes).padStart(2, "0") + ":" + String(remainder).padStart(2, "0");
164
- element.textContent = labels.elapsed + duration;
167
+ const shortDuration = hours > 0
168
+ ? String(hours * 60 + minutes) + ":" + String(remainder).padStart(2, "0")
169
+ : minutes > 0
170
+ ? String(minutes) + ":" + String(remainder).padStart(2, "0")
171
+ : remainder > 0 ? String(remainder) + "s" : "";
172
+ element.textContent = element.dataset.shortDuration === "1" ? shortDuration : labels.elapsed + duration;
165
173
  });
166
174
  };
167
175
  updateElapsed();
@@ -284,9 +292,10 @@ export function renderTurnPreviewSnapshot(
284
292
  if (!state.projectCwd || !path.isAbsolute(state.projectCwd)) return undefined;
285
293
  return localFileUrl(path.resolve(state.projectCwd, filePath));
286
294
  } : undefined;
287
- const timeline = renderTimeline(state.activities ?? [], resolveFileUrl, state.fullToolOutputs, state.fullToolErrors, language);
295
+ const renderText = (value: string): string => renderMarkdown(value, state.projectCwd, localFileUrl);
296
+ const timeline = renderTimeline(state.activities ?? [], renderText, resolveFileUrl, state.fullToolOutputs, state.fullToolErrors, language, state.projectCwd);
288
297
  const sections = [
289
- state.prompt ? renderMessageActivity("prompt", state.prompt, "user prompt") : "",
298
+ state.prompt ? renderMessageActivity("prompt", state.prompt, "user prompt", renderText) : "",
290
299
  state.plan.length > 0 ? renderPlan(state, language) : "",
291
300
  state.activitiesTruncated
292
301
  ? `<div class="notice">${escapeHtml(labels.truncated)}</div>`
@@ -295,9 +304,9 @@ export function renderTurnPreviewSnapshot(
295
304
  state.fileSummary.length > 0 ? renderFileSummary(state, language, resolveFileUrl) : "",
296
305
  state.error ? `<section class="result error-result"><h2>${escapeHtml(labels.error)}</h2><pre>${escapeHtml(state.error)}</pre></section>` : "",
297
306
  state.finalResponse
298
- ? `<section class="result final-result"><h2>${escapeHtml(labels.finalAnswer)}</h2><div class="markdown">${renderMarkdown(state.finalResponse)}</div></section>`
307
+ ? `<section class="result final-result"><h2>${escapeHtml(labels.finalAnswer)}</h2><div class="markdown">${renderText(state.finalResponse)}</div></section>`
299
308
  : state.assistantText
300
- ? `<section class="result"><h2>${escapeHtml(labels.generating)}</h2><div class="markdown">${renderMarkdown(state.assistantText)}</div></section>`
309
+ ? `<section class="result"><h2>${escapeHtml(labels.generating)}</h2><div class="markdown">${renderText(state.assistantText)}</div></section>`
301
310
  : "",
302
311
  ].filter(Boolean).join("");
303
312
  const terminal = isTerminal(state.status);
@@ -322,36 +331,40 @@ function renderPlan(state: TurnViewState, language: TurnPreviewLanguage): string
322
331
 
323
332
  function renderTimeline(
324
333
  activities: TurnActivity[],
334
+ renderText: (value: string) => string,
325
335
  localFileUrl?: (filePath: string) => string | undefined,
326
336
  fullToolOutputs?: Record<string, string>,
327
337
  fullToolErrors?: Record<string, string>,
328
338
  language: TurnPreviewLanguage = "zh",
339
+ projectCwd?: string,
329
340
  ): string {
330
341
  return activities
331
342
  .filter((activity): activity is Exclude<TurnActivity, { kind: "reasoning" }> => activity.kind !== "reasoning")
332
- .map((activity) => renderActivity(activity, localFileUrl, fullToolOutputs, fullToolErrors, language))
343
+ .map((activity) => renderActivity(activity, renderText, localFileUrl, fullToolOutputs, fullToolErrors, language, projectCwd))
333
344
  .join("");
334
345
  }
335
346
 
336
347
  function renderActivity(
337
348
  activity: Exclude<TurnActivity, { kind: "reasoning" }>,
349
+ renderText: (value: string) => string,
338
350
  localFileUrl?: (filePath: string) => string | undefined,
339
351
  fullToolOutputs?: Record<string, string>,
340
352
  fullToolErrors?: Record<string, string>,
341
353
  language: TurnPreviewLanguage = "zh",
354
+ projectCwd?: string,
342
355
  ): string {
343
356
  switch (activity.kind) {
344
357
  case "assistant":
345
- return renderMessageActivity(activity.id, activity.text, "commentary");
358
+ return renderMessageActivity(activity.id, activity.text, "commentary", renderText);
346
359
  case "user":
347
- return renderMessageActivity(activity.id, activity.text, "user");
360
+ return renderMessageActivity(activity.id, activity.text, "user", renderText);
348
361
  case "tool":
349
- return renderToolActivity(activity.id, activity.tool, localFileUrl, fullToolOutputs, fullToolErrors, language);
362
+ return renderToolActivity(activity.id, activity.tool, localFileUrl, fullToolOutputs, fullToolErrors, language, projectCwd);
350
363
  }
351
364
  }
352
365
 
353
- function renderMessageActivity(id: string, text: string, kind: string): string {
354
- return `<article class="activity message ${kind}" data-activity="${escapeAttribute(id)}"><div class="markdown">${renderMarkdown(text)}</div></article>`;
366
+ function renderMessageActivity(id: string, text: string, kind: string, renderText: (value: string) => string): string {
367
+ return `<article class="activity message ${kind}" data-activity="${escapeAttribute(id)}"><div class="markdown">${renderText(text)}</div></article>`;
355
368
  }
356
369
 
357
370
  function renderToolActivity(
@@ -361,6 +374,7 @@ function renderToolActivity(
361
374
  fullToolOutputs?: Record<string, string>,
362
375
  fullToolErrors?: Record<string, string>,
363
376
  language: TurnPreviewLanguage = "zh",
377
+ projectCwd?: string,
364
378
  ): string {
365
379
  const labels = previewLabels(language);
366
380
  const command = tool.command ? (displayToolCommand(tool.command) || tool.command.trim()) : undefined;
@@ -370,29 +384,40 @@ function renderToolActivity(
370
384
  : command
371
385
  ? formatToolCommand(command)
372
386
  : undefined;
373
- const commandInHeader = Boolean(displayCommand);
374
- const title = commandInHeader ? displayCommand! : toolTitle(tool, command, repl, language);
387
+ const title = displayCommand ? summarizeToolCommand(displayCommand) : toolTitle(tool, command, repl, language);
375
388
  const output = fullToolOutputs?.[tool.id] ?? tool.output;
376
389
  const error = fullToolErrors?.[tool.id] ?? tool.error;
377
390
  const sameError = Boolean(output && error && normalizeOutput(output) === normalizeOutput(error));
391
+ const displayOutput = output && repl && !sameError ? formatReplOutput(output) : output;
392
+ const displayError = sameError ? undefined : error;
378
393
  const detailParts = [
379
- displayCommand && !commandInHeader ? `<pre class="command-block${repl ? " repl-code" : ""}" data-scroll-id="${escapeAttribute(`${activityId}:command`)}">${escapeHtml(displayCommand)}</pre>` : "",
380
- output
381
- ? renderToolOutput(activityId, "output", sameError ? labels.resultError : repl ? labels.result : labels.output, output, sameError, repl, language, tool)
394
+ displayCommand ? `<div class="tool-command"><span class="tool-command-prefix" aria-hidden="true">$</span><pre class="command-block${repl ? " repl-code" : ""}">${escapeHtml(displayCommand)}</pre></div>` : "",
395
+ displayOutput
396
+ ? renderToolOutput(sameError ? labels.resultError : repl ? labels.result : labels.output, displayOutput, sameError, repl)
382
397
  : "",
383
- error && !sameError
384
- ? renderToolOutput(activityId, "error", labels.error, error, true, repl, language, tool)
398
+ displayError
399
+ ? renderToolOutput(labels.error, displayError, true, repl)
385
400
  : "",
386
- !output && !error && !tool.files?.length ? renderToolStatusRow(tool, language) : "",
387
401
  renderToolImage(tool, localFileUrl, language),
388
402
  tool.files?.length
389
- ? `<details class="tool-output" data-activity-id="${escapeAttribute(`${activityId}:files`)}"><summary><span class="tool-output-label">${escapeHtml(labels.files)}</span><span class="tool-output-meta">${renderToolTiming(tool, language)}<span class="tool-state ${tool.status}" aria-label="${escapeAttribute(tool.status)}">${toolStatusIcon(tool.status, language)}</span><span>${tool.files.length}</span></span></summary><ul class="files">${tool.files.map((file) => renderFileEntry(file, localFileUrl)).join("")}</ul></details>`
403
+ ? `<ul class="files" aria-label="${escapeAttribute(labels.files)}">${tool.files.map((file) => renderFileEntry(file, localFileUrl, projectCwd)).join("")}</ul>`
390
404
  : "",
391
405
  ].filter(Boolean).join("");
392
- const titleMarkup = commandInHeader
406
+ const titleMarkup = displayCommand
393
407
  ? `<code class="tool-command-title">${escapeHtml(title)}</code>`
394
408
  : `<span class="tool-title">${escapeHtml(title)}</span>`;
395
- return `<article class="activity tool"><section class="tool-step" data-activity-id="${escapeAttribute(activityId)}"><div class="tool-header" data-scroll-id="${escapeAttribute(`${activityId}:command`)}">${titleMarkup}</div><div class="tool-body">${detailParts || `<div class="muted">${escapeHtml(labels.noToolDetails)}</div>`}</div></section></article>`;
409
+ const timing = renderToolTiming(tool, "header");
410
+ const statusLabel = toolStatusLabel(tool.status, language);
411
+ const icon = `<span class="tool-status-icon" role="img" aria-label="${escapeAttribute(statusLabel)}" title="${escapeAttribute(statusLabel)}">${toolStatusIcon(tool.status)}</span>`;
412
+ const status = `<span class="tool-state ${tool.status}">${escapeHtml(statusLabel)}</span>`;
413
+ const header = `<summary class="tool-header">${icon}<span class="tool-summary-content">${titleMarkup}</span><span class="tool-meta">${timing}</span></summary>`;
414
+ const startedAt = tool.startedAt === undefined ? undefined : new Date(tool.startedAt);
415
+ const startTime = startedAt
416
+ ? `<time datetime="${startedAt.toISOString()}" title="${escapeAttribute(labels.startingTime)}">${new Intl.DateTimeFormat(language === "zh" ? "zh-CN" : "en-GB", { hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23" }).format(startedAt)}</time>`
417
+ : `<span title="${escapeAttribute(labels.startingTime)}">${escapeHtml(labels.unknown)}</span>`;
418
+ const characterCount = (displayOutput?.length ?? 0) + (displayError?.length ?? 0);
419
+ const footer = `<div class="tool-footer">${status}${startTime}${renderToolTiming(tool, "footer")}<span>${formatNumber(characterCount)} ${escapeHtml(labels.characters)}</span></div>`;
420
+ return `<article class="activity tool"><details class="tool-step" data-tool-status="${tool.status}" data-activity-id="${escapeAttribute(activityId)}">${header}<div class="tool-body"><div class="tool-content" data-scroll-id="${escapeAttribute(`${activityId}:content`)}" tabindex="0">${detailParts || `<div class="muted">${escapeHtml(labels.noToolDetails)}</div>`}</div>${footer}</div></details></article>`;
396
421
  }
397
422
 
398
423
  function toolTitle(tool: ToolState, command?: string, repl = false, language: TurnPreviewLanguage = "zh"): string {
@@ -402,6 +427,12 @@ function toolTitle(tool: ToolState, command?: string, repl = false, language: Tu
402
427
  return title || tool.kind;
403
428
  }
404
429
 
430
+ function summarizeToolCommand(command: string): string {
431
+ const summary = command.replace(/[\\`^][ \t]*\r?\n/gu, " ").replace(/\s+/gu, " ").trim();
432
+ if (summary.length <= 160) return summary;
433
+ return `${summary.slice(0, 157).trimEnd()}…`;
434
+ }
435
+
405
436
  function isReplTool(tool: ToolState): boolean {
406
437
  const title = tool.title.trim();
407
438
  const titleLooksLikeToolName = title.length <= 120 && !/[\s"'`]/u.test(title);
@@ -512,36 +543,29 @@ function normalizeReplText(value: string): string {
512
543
  }
513
544
 
514
545
  function renderToolOutput(
515
- activityId: string,
516
- kind: string,
517
546
  label: string,
518
547
  value: string,
519
548
  error = false,
520
549
  repl = false,
521
- language: TurnPreviewLanguage = "zh",
522
- tool?: ToolState,
523
550
  ): string {
524
- const id = escapeAttribute(`${activityId}:${kind}`);
525
- const displayValue = repl && !error ? formatReplOutput(value) : value;
526
- const labels = previewLabels(language);
527
- const status = tool?.status ?? "completed";
528
- const timing = tool ? renderToolTiming(tool, language) : "";
529
- return `<details class="tool-output${error ? " error-output" : ""}${repl ? " repl-result" : ""}" data-activity-id="${id}"><summary><span class="tool-output-label">${escapeHtml(label)}</span><span class="tool-output-meta">${timing}<span class="tool-state ${status}" aria-label="${escapeAttribute(status)}">${toolStatusIcon(status, language)}</span><span>${formatNumber(displayValue.length)} ${escapeHtml(labels.characters)}</span></span></summary><pre data-scroll-id="${id}">${escapeHtml(displayValue)}</pre></details>`;
551
+ return `<pre class="tool-output${error ? " error-output" : ""}${repl ? " repl-result" : ""}" aria-label="${escapeAttribute(label)}">${escapeHtml(value)}</pre>`;
530
552
  }
531
553
 
532
- function renderToolStatusRow(tool: ToolState, language: TurnPreviewLanguage): string {
533
- const timing = renderToolTiming(tool, language);
534
- return `<div class="tool-status-row"><span class="tool-output-meta">${timing}<span class="tool-state ${tool.status}" aria-label="${escapeAttribute(tool.status)}">${toolStatusIcon(tool.status, language)}</span></span></div>`;
554
+ function renderToolTiming(tool: ToolState, placement: "header" | "footer"): string {
555
+ const className = `tool-${placement}-timing`;
556
+ const duration = toolDuration(tool);
557
+ if (duration === undefined) {
558
+ return `<span class="${className}">?</span>`;
559
+ }
560
+ const live = tool.status === "running" && tool.completedAt === undefined;
561
+ return `<span class="${className}"${live ? ` data-live-tool-duration data-started-at="${tool.startedAt}" data-short-duration="1"` : ""}>${formatShortDuration(duration)}</span>`;
535
562
  }
536
563
 
537
- function renderToolTiming(tool: ToolState, language: TurnPreviewLanguage): string {
538
- const labels = previewLabels(language);
539
- const start = tool.startedAt === undefined ? labels.unknown : formatTimestamp(tool.startedAt);
540
- const duration = toolDuration(tool);
541
- const liveDuration = duration === undefined
542
- ? `<span>${escapeHtml(labels.elapsed.trim())}${language === "zh" ? "" : " "}${escapeHtml(labels.unknown)}</span>`
543
- : `<span${tool.status === "running" && tool.completedAt === undefined ? ` data-live-tool-duration data-started-at="${tool.startedAt}"` : ""}>${escapeHtml(labels.elapsed)}${escapeHtml(duration)}</span>`;
544
- return `<span title="${escapeAttribute(labels.startingTime)}">${escapeHtml(start)}</span>${liveDuration}`;
564
+ function formatShortDuration(duration: string): string {
565
+ const parts = duration.split(":").map(Number);
566
+ const seconds = parts.at(-1) ?? 0;
567
+ const minutes = parts.length > 2 ? (parts.at(-2) ?? 0) + (parts.at(-3) ?? 0) * 60 : parts.at(-2) ?? 0;
568
+ return minutes > 0 ? `${minutes}:${String(seconds).padStart(2, "0")}` : seconds > 0 ? `${seconds}s` : "";
545
569
  }
546
570
 
547
571
  function renderToolImage(
@@ -567,12 +591,12 @@ function renderFileSummary(
567
591
  localFileUrl?: (filePath: string) => string | undefined,
568
592
  ): string {
569
593
  const labels = previewLabels(language);
570
- const files = state.fileSummary.map((file) => renderFileEntry(file, localFileUrl)).join("");
571
- return `<section class="files-summary"><h2>${escapeHtml(labels.fileChanges)} <span>${state.fileSummary.length}</span></h2><ul class="files">${files}</ul></section>`;
594
+ const files = state.fileSummary.map((file) => renderFileEntry(file, localFileUrl, state.projectCwd)).join("");
595
+ return `<details class="files-summary" data-activity-id="turn:files-summary"><summary><h2>${escapeHtml(labels.fileChanges)} <span>${state.fileSummary.length}</span></h2></summary><ul class="files">${files}</ul></details>`;
572
596
  }
573
597
 
574
- function renderFileEntry(file: FileSummary, localFileUrl?: (filePath: string) => string | undefined): string {
575
- const label = `<code>${escapeHtml(file.path)}</code>`;
598
+ function renderFileEntry(file: FileSummary, localFileUrl?: (filePath: string) => string | undefined, projectCwd?: string): string {
599
+ const label = `<code>${escapeHtml(displayFilePath(file.path, projectCwd))}</code>`;
576
600
  const url = localFileUrl?.(file.path);
577
601
  const link = url
578
602
  ? `<a class="file-link" href="${escapeAttribute(url)}" target="_blank" rel="noreferrer noopener">${label}</a>`
@@ -608,15 +632,23 @@ function previewTitle(state: TurnViewState): string {
608
632
  return value.length > 80 ? `${value.slice(0, 79)}…` : value;
609
633
  }
610
634
 
611
- function renderMarkdown(value: string): string {
612
- return MARKDOWN.render(value);
635
+ function renderMarkdown(value: string, projectCwd?: string, localFileUrl?: (filePath: string) => string | undefined): string {
636
+ const environment = {};
637
+ const tokens = MARKDOWN.parse(value, environment);
638
+ // Only the directory is used to resolve relative links; no Markdown file is created.
639
+ const markdownPath = projectCwd && path.isAbsolute(projectCwd) ? path.join(projectCwd, "turn.md") : undefined;
640
+ rewriteMarkdownFileLinks(tokens, markdownPath, (filePath) => {
641
+ const url = localFileUrl?.(filePath);
642
+ return url ? new URL(url) : undefined;
643
+ });
644
+ return MARKDOWN.renderer.render(tokens, MARKDOWN.options, environment);
613
645
  }
614
646
 
615
647
  function statusLabel(status: TurnViewStatus, language: TurnPreviewLanguage): string {
616
648
  return previewLabels(language).statuses[status];
617
649
  }
618
650
 
619
- function toolStatusIcon(status: ToolState["status"], language: TurnPreviewLanguage): string {
651
+ function toolStatusLabel(status: ToolState["status"], language: TurnPreviewLanguage): string {
620
652
  return status === "completed"
621
653
  ? previewLabels(language).toolStatuses.completed
622
654
  : status === "failed"
@@ -640,15 +672,6 @@ function formatDuration(durationMs: number): string {
640
672
  : `${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`;
641
673
  }
642
674
 
643
- function formatTimestamp(timestampMs: number): string {
644
- return new Intl.DateTimeFormat(undefined, {
645
- hour: "2-digit",
646
- minute: "2-digit",
647
- second: "2-digit",
648
- hour12: false,
649
- }).format(new Date(timestampMs));
650
- }
651
-
652
675
  function formatNumber(value: number): string {
653
676
  return Math.max(0, Math.floor(value)).toLocaleString("en-US");
654
677
  }
@@ -673,7 +696,7 @@ function escapeAttribute(value: string): string {
673
696
  }
674
697
 
675
698
  const TURN_PREVIEW_CSS = `
676
- :root { color-scheme: light dark; font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; --page: #fff; --surface: #f5f6f7; --code: #fafafa; --text: #25272b; --muted: #62666d; --line: #e0e2e5; --accent: #245bc0; --success: #257449; --danger: #b52c34; }
699
+ :root { color-scheme: light dark; font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; --page: #fff; --surface: #f5f6f7; --code: #fafafa; --text: #25272b; --muted: #62666d; --line: #e0e2e5; --accent: #245bc0; --success: #257449; --danger: #b52c34; --scrollbar-thumb: #d4d7dc; --scrollbar-thumb-hover: #afb4bc; }
677
700
  * { box-sizing: border-box; letter-spacing: 0; }
678
701
  body { margin: 0; background: var(--page); color: var(--text); font-size: 13px; line-height: 1.55; }
679
702
  .page-header { position: sticky; top: 0; z-index: 3; background: var(--page); border-bottom: 1px solid var(--line); }
@@ -702,16 +725,21 @@ h2 span { margin-left: 6px; color: var(--muted); font-size: 12px; font-weight: 4
702
725
  .metadata > span + span::before { content: "·"; position: absolute; left: 0; top: 0; }
703
726
  .metadata strong { color: var(--text); font-weight: 500; font-variant-numeric: tabular-nums; }
704
727
  main { max-width: 1320px; margin: 0 auto; padding: 0 20px 40px; }
705
- .plan, .files-summary, .result { padding: 14px 0; border-bottom: 1px solid var(--line); }
728
+ .plan, .files-summary, .result { padding: 14px 0; }
729
+ .files-summary, .result { border-top: 1px solid var(--line); }
730
+ .files-summary > summary h2 { margin: 0; }
731
+ .files-summary > .files { padding-top: 8px; }
706
732
  .plan ol { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }
707
733
  .plan li { display: grid; grid-template-columns: 16px minmax(0, 1fr); gap: 6px; overflow-wrap: anywhere; }
708
734
  .plan li.completed { color: var(--muted); }
709
735
  .plan li.in_progress { color: var(--accent); }
710
736
  .timeline { padding-top: 2px; }
711
737
  .activity { min-width: 0; }
712
- .message { padding: 12px 0; border-bottom: 1px solid var(--line); }
738
+ .message { padding: 12px 0; }
739
+ .prompt { border-bottom: 1px solid var(--line); }
740
+ .timeline .markdown hr { display: none; }
713
741
  .markdown { min-width: 0; overflow-wrap: anywhere; }
714
- .commentary > .markdown { font-weight: 700; }
742
+ .message > .markdown, .result > .markdown { font-size: 14px; font-weight: 400; }
715
743
  .markdown > :first-child { margin-top: 0; }
716
744
  .markdown > :last-child { margin-bottom: 0; }
717
745
  .markdown p, .markdown ul, .markdown ol, .markdown pre, .markdown blockquote { margin: 0 0 6px; }
@@ -734,32 +762,52 @@ summary:hover { background: transparent; }
734
762
  summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
735
763
  .tool { margin: 8px 0; }
736
764
  .tool-step { border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: transparent; }
737
- .tool-header { display: flex; align-items: flex-start; gap: 8px; min-height: 32px; padding: 7px 10px; overflow-x: auto; background: transparent; font-weight: 400; }
738
- .tool-state { display: inline-flex; flex: none; align-items: center; justify-content: center; color: var(--muted); font-size: 11px; font-weight: 650; white-space: nowrap; }
765
+ .tool-step[data-tool-status="running"] { border-color: var(--accent); }
766
+ .tool-header { display: flex; align-items: center; gap: 8px; min-height: 32px; padding: 7px 10px; overflow: hidden; background: transparent; font-weight: 400; }
767
+ .tool-content { max-height: calc(30 * 12px * 1.55 + 16px); padding: 8px 12px; overflow: auto; --tool-scrollbar-thumb: var(--scrollbar-thumb); scrollbar-width: thin; scrollbar-color: var(--tool-scrollbar-thumb) transparent; }
768
+ .tool-content > * + * { margin-top: 8px; }
769
+ .tool-content pre { max-width: none; max-height: none; padding: 0; overflow: visible; background: transparent; }
770
+ .tool-command { display: flex; align-items: flex-start; gap: 8px; width: max-content; min-width: 100%; }
771
+ .tool-command-prefix { flex: none; color: var(--muted); font: 12px/1.55 "Cascadia Mono", "SFMono-Regular", Consolas, monospace; }
772
+ .tool-footer { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; padding: 6px 12px; color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
773
+ .tool-footer > * { white-space: nowrap; }
774
+ .tool-footer > * + *::before { content: "·"; margin-right: 8px; }
775
+ @media (hover: hover) and (pointer: fine) {
776
+ .tool-content { --tool-scrollbar-thumb: transparent; }
777
+ .tool-content:is(:hover, :focus-visible, :focus-within) { --tool-scrollbar-thumb: var(--scrollbar-thumb); }
778
+ }
779
+ @supports selector(::-webkit-scrollbar) {
780
+ .tool-content { scrollbar-width: auto; scrollbar-color: auto; }
781
+ .tool-content::-webkit-scrollbar { width: 6px; height: 6px; }
782
+ .tool-content::-webkit-scrollbar-track,
783
+ .tool-content::-webkit-scrollbar-corner { background: transparent; }
784
+ .tool-content::-webkit-scrollbar-thumb { background: var(--tool-scrollbar-thumb); border-radius: 3px; }
785
+ .tool-content::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); }
786
+ .tool-content::-webkit-scrollbar-button { display: none; width: 0; height: 0; }
787
+ }
788
+ @media (hover: none), (pointer: coarse) {
789
+ .tool-content { scrollbar-width: none; }
790
+ .tool-content::-webkit-scrollbar { display: none; width: 0; height: 0; }
791
+ }
792
+ .tool-status-icon { display: inline-flex; flex: 0 0 16px; align-items: center; justify-content: center; font-size: 13px; line-height: 18px; }
793
+ .tool-state { display: inline-flex; flex: none; align-items: center; justify-content: center; color: var(--muted); font-size: 11px; font-weight: 400; white-space: nowrap; }
739
794
  .tool-state.completed { color: var(--success); }
740
795
  .tool-state.running { color: var(--accent); }
741
796
  .tool-state.failed { color: var(--danger); }
742
- .tool-title { min-width: 0; flex: 1 0 auto; overflow-wrap: normal; font-size: 12px; font-weight: 400; line-height: 1.45; white-space: pre; }
743
- .tool-command-title { display: block; min-width: 0; flex: 1 0 auto; margin: 0; padding: 0; overflow-wrap: normal; background: transparent; color: var(--text); font: 12px/1.55 "Cascadia Mono", "SFMono-Regular", Consolas, monospace; font-weight: 400; white-space: pre; }
744
- .tool-meta { display: flex; flex: 0 1 auto; flex-wrap: nowrap; gap: 12px; max-width: 48%; overflow: hidden; color: var(--muted); font-size: 11px; font-weight: 400; font-variant-numeric: tabular-nums; white-space: nowrap; }
797
+ .tool-summary-content { min-width: 0; flex: 1 1 auto; overflow: hidden; }
798
+ .tool-title, .tool-command-title { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
799
+ .tool-title { font-size: 12px; font-weight: 400; line-height: 1.45; }
800
+ .tool-command-title { margin: 0; padding: 0; background: transparent; color: var(--text); font: 12px/1.55 "Cascadia Mono", "SFMono-Regular", Consolas, monospace; font-weight: 400; }
801
+ .tool-meta { display: flex; flex: 0 0 auto; flex-wrap: nowrap; align-items: center; gap: 12px; max-width: none; margin-left: auto; color: var(--muted); font-size: 11px; font-weight: 400; font-variant-numeric: tabular-nums; white-space: nowrap; }
745
802
  .tool-meta span { white-space: nowrap; }
746
- .tool-body { border-top: 1px solid var(--line); }
747
- .tool-body > .muted { padding: 8px 12px; }
748
- .tool-output { border-top: 1px solid var(--line); }
749
- .tool-output:first-child { border-top: 0; }
750
- .tool-output > summary { padding: 4px 12px; background: transparent; color: var(--muted); font-size: 12px; }
751
- .tool-output-label { min-width: 0; }
752
- .tool-output-meta { display: inline-flex; align-items: center; gap: 12px; margin-left: auto; font-size: 11px; }
753
- .tool-status-row { display: flex; justify-content: flex-end; padding: 5px 12px; border-top: 1px solid var(--line); }
754
- .tool-output > pre { max-height: none; border-top: 1px solid var(--line); background: transparent; }
755
- .repl-code { background: transparent; }
756
- .repl-result > pre { white-space: pre; overflow-wrap: normal; background: transparent; }
757
- .error-output > summary, .error-result h2 { color: var(--danger); }
803
+ .tool-header-timing { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
804
+ .tool-header-timing:empty, .tool-footer-timing:empty { display: none; }
805
+ .error-output, .error-result h2 { color: var(--danger); }
758
806
  .muted, .empty { color: var(--muted); }
759
- .notice, .empty { padding: 10px 0; border-bottom: 1px solid var(--line); }
807
+ .notice, .empty { padding: 10px 0; }
760
808
  .notice { color: #926a08; }
761
809
  .files { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }
762
- .tool-output .files { padding: 8px 12px; }
810
+ .tool-content .files code { background: transparent; }
763
811
  .files li { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
764
812
  .files code { min-width: 0; }
765
813
  .files .file-link { min-width: 0; color: var(--accent); text-underline-offset: 2px; }
@@ -767,7 +815,6 @@ summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px;
767
815
  .additions { color: var(--success); }
768
816
  .deletions { color: var(--danger); }
769
817
  .file-delta { display: inline-flex; flex: none; gap: 6px; font: 12px "Cascadia Mono", Consolas, monospace; }
770
- .tool-image { padding: 8px 12px; border-top: 1px solid var(--line); }
771
818
  .tool-image img { display: block; max-width: 100%; max-height: 720px; }
772
819
  .final-result h2 { color: var(--success); }
773
820
  @media (max-width: 700px) {
@@ -780,13 +827,12 @@ summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px;
780
827
  .message { padding: 10px 0; }
781
828
  .tool-header { gap: 6px; padding: 7px 9px; }
782
829
  .tool-state { font-size: 11px; }
783
- .tool-meta { flex: 0 1 auto; gap: 10px; max-width: 50%; font-size: 11px; }
784
- .tool-output > summary { min-height: 40px; padding: 6px 10px; }
785
- .tool-output-meta { gap: 10px; }
830
+ .tool-meta { gap: 10px; font-size: 11px; }
831
+ .tool-content, .tool-footer { padding-left: 10px; padding-right: 10px; }
786
832
  pre { padding: 8px 10px; }
787
833
  }
788
834
  @media (prefers-color-scheme: dark) {
789
- :root { --page: #18191b; --surface: #232427; --code: #1d1e20; --text: #e0e1e4; --muted: #a0a3ab; --line: #35373c; --accent: #8aaff7; --success: #83c69c; --danger: #f58c94; }
835
+ :root { --page: #18191b; --surface: #232427; --code: #1d1e20; --text: #e0e1e4; --muted: #a0a3ab; --line: #35373c; --accent: #8aaff7; --success: #83c69c; --danger: #f58c94; --scrollbar-thumb: #45494f; --scrollbar-thumb-hover: #60666e; }
790
836
  .notice, .status.waiting_for_approval { color: #e1bc6f; }
791
837
  }
792
838
  `;
@@ -487,6 +487,7 @@ export interface ProxyLifecycle {
487
487
  supervised?: boolean;
488
488
  restart(contextKey: string, force: boolean, replyTarget?: MessageReplyTarget): Promise<void>;
489
489
  cancelSafeRestart?(scheduleId: number): Promise<boolean>;
490
+ cancelAutomaticUpdate?(action: CardAction): Promise<void>;
490
491
  rememberFeishuUserOpenId?(userOpenId: string): Promise<void> | void;
491
492
  persistAgentExecutionDefaults?(
492
493
  agentName: string,
@@ -975,7 +976,10 @@ export class ProxySessionController {
975
976
  await this.outbound.withReplyTarget(contextKey, replyTarget, async () => {
976
977
  try {
977
978
  const kind = String(scopedAction.value.action ?? "");
978
- if (kind === "help_command") {
979
+ if (kind === "agentbot_update_cancel") {
980
+ if (!this.lifecycle?.cancelAutomaticUpdate) throw new Error("自动更新功能未启用。");
981
+ await this.lifecycle.cancelAutomaticUpdate(scopedAction);
982
+ } else if (kind === "help_command") {
979
983
  await this.executeHelpCommandAction(scopedAction, contextKey, replyTarget);
980
984
  } else if (kind === "turn_details") {
981
985
  await this.outbound.showDetails(contextKey, String(scopedAction.value.turnId ?? ""));
@@ -1708,6 +1712,7 @@ export class ProxySessionController {
1708
1712
  await loaded.runtime.setReasoningEffort(record.localSessionId, nextEffort);
1709
1713
  }
1710
1714
  this.store.updateRuntimeSession(record.localSessionId, { model: nextValue, reasoningEffort: nextEffort });
1715
+ this.outbound.updateSessionModel(record.localSessionId, nextValue);
1711
1716
  } else if (setting === "thinking" && nextValue) {
1712
1717
  const models = await loaded.runtime.listModels(loaded.session.modelProvider, loaded.session.model);
1713
1718
  const currentModel = models.find((candidate) => candidate.id === loaded.session.model)
@@ -3734,6 +3739,10 @@ export class ProxySessionController {
3734
3739
  while (true) {
3735
3740
  let pendingTurnId: string | undefined;
3736
3741
  try {
3742
+ this.outbound.updateSessionModel(
3743
+ loaded.record.localSessionId,
3744
+ loaded.runtime.getSession(loaded.record.localSessionId)?.model ?? loaded.session.model,
3745
+ );
3737
3746
  pendingTurnId = await this.outbound.startPendingTurn(
3738
3747
  loaded.record.localSessionId,
3739
3748
  loaded.record.contextKey,
@@ -5187,7 +5196,9 @@ export class ProxySessionController {
5187
5196
  throw error;
5188
5197
  }
5189
5198
  };
5190
- return loaded.runtime.setExecutionSettings(loaded.record.localSessionId, settings, persist);
5199
+ const session = await loaded.runtime.setExecutionSettings(loaded.record.localSessionId, settings, persist);
5200
+ this.outbound.updateSessionModel(loaded.record.localSessionId, session.model);
5201
+ return session;
5191
5202
  }
5192
5203
 
5193
5204
  private async assertProviderModelSettings(
@@ -5249,6 +5260,7 @@ export class ProxySessionController {
5249
5260
  await loaded.runtime.setReasoningEffort(loaded.record.localSessionId, nextEffort);
5250
5261
  }
5251
5262
  this.store.updateRuntimeSession(loaded.record.localSessionId, { model, reasoningEffort: nextEffort });
5263
+ this.outbound.updateSessionModel(loaded.record.localSessionId, model);
5252
5264
  await this.persistAgentExecutionDefaults(loaded.record.localSessionId);
5253
5265
  const effortMessage = nextEffort && nextEffort !== currentEffort
5254
5266
  ? `,思考强度已自动调整为 ${nextEffort}`
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import Database from "better-sqlite3";
4
4
  import { migrations } from "./migrations.js";
5
+ import type { UpdateCheckSchedule, UpdateNotice } from "../updates/types.js";
5
6
 
6
7
  export type SessionStatus = "starting" | "ready" | "running" | "closed" | "failed";
7
8
 
@@ -287,6 +288,36 @@ export class StateStore {
287
288
  `);
288
289
  }
289
290
 
291
+ getUpdateCheckSchedule(): UpdateCheckSchedule | undefined {
292
+ const row = this.db.prepare("SELECT value_json FROM update_check_schedule WHERE id = 1")
293
+ .get() as { value_json: string } | undefined;
294
+ return row ? JSON.parse(row.value_json) as UpdateCheckSchedule : undefined;
295
+ }
296
+
297
+ saveUpdateCheckSchedule(schedule: UpdateCheckSchedule): void {
298
+ this.db.prepare(`INSERT INTO update_check_schedule (id, value_json) VALUES (1, ?)
299
+ ON CONFLICT(id) DO UPDATE SET value_json = excluded.value_json`).run(JSON.stringify(schedule));
300
+ }
301
+
302
+ getUpdateNotice(version: string): UpdateNotice | undefined {
303
+ const row = this.db.prepare("SELECT value_json FROM update_notices WHERE version = ?")
304
+ .get(version) as { value_json: string } | undefined;
305
+ return row ? JSON.parse(row.value_json) as UpdateNotice : undefined;
306
+ }
307
+
308
+ saveUpdateNotice(notice: UpdateNotice): void {
309
+ this.db.prepare(`INSERT INTO update_notices (version, status, value_json) VALUES (?, ?, ?)
310
+ ON CONFLICT(version) DO UPDATE SET status = excluded.status, value_json = excluded.value_json`)
311
+ .run(notice.version, notice.status, JSON.stringify(notice));
312
+ }
313
+
314
+ listPendingUpdateNotices(): UpdateNotice[] {
315
+ const rows = this.db.prepare(`SELECT value_json FROM update_notices
316
+ WHERE status IN ('announcing', 'countdown', 'preparing', 'scheduled')`)
317
+ .all() as Array<{ value_json: string }>;
318
+ return rows.map((row) => JSON.parse(row.value_json) as UpdateNotice);
319
+ }
320
+
290
321
  saveGoalCardDelivery(localSessionId: string, contextKey: string, messageId: string): void {
291
322
  const updatedAt = new Date().toISOString();
292
323
  this.db.prepare(`
@@ -195,4 +195,16 @@ export const migrations = [
195
195
  CREATE INDEX IF NOT EXISTS idx_card_action_bindings_created
196
196
  ON card_action_bindings(created_at);
197
197
  `,
198
+ `
199
+ CREATE TABLE IF NOT EXISTS update_check_schedule (
200
+ id INTEGER PRIMARY KEY CHECK (id = 1),
201
+ value_json TEXT NOT NULL
202
+ );
203
+ CREATE TABLE IF NOT EXISTS update_notices (
204
+ version TEXT PRIMARY KEY,
205
+ status TEXT NOT NULL,
206
+ value_json TEXT NOT NULL
207
+ );
208
+ CREATE INDEX IF NOT EXISTS idx_update_notices_status ON update_notices(status);
209
+ `,
198
210
  ] as const;
@@ -0,0 +1,76 @@
1
+ import { execFile, type ExecFileOptionsWithStringEncoding } from "node:child_process";
2
+ import { fileURLToPath, pathToFileURL } from "node:url";
3
+ import { promisify } from "node:util";
4
+ import { z } from "zod";
5
+ import { loadConfig } from "../config/loadConfig.js";
6
+ import { controlEndpoint } from "../cli/controlProtocol.js";
7
+ import { finalizeSelfUpdatePlan, prepareSelfUpdate, releaseSelfUpdatePlan, requireNpmSelfUpdateInstallation } from "../cli/SelfUpdater.js";
8
+ import { stableVersionSchema } from "./PublishedRelease.js";
9
+
10
+ const resultSchema = z.discriminatedUnion("status", [
11
+ z.object({ status: z.literal("current") }),
12
+ z.object({ status: z.literal("prepared"), planPath: z.string().min(1), targetVersion: stableVersionSchema }),
13
+ ]);
14
+ const runFile = promisify(execFile);
15
+ type RunPreparation = (file: string, args: string[], options: ExecFileOptionsWithStringEncoding) => Promise<{ stdout: string }>;
16
+ interface UpdateProfile { home: string; configPath: string }
17
+
18
+ export async function checkAutomaticUpdateSupport(profile: UpdateProfile, run: RunPreparation = runFile): Promise<void> {
19
+ z.object({ status: z.literal("supported") }).parse(await runPreparer("--check", profile, run));
20
+ }
21
+
22
+ export async function prepareAutomaticUpdate(
23
+ version: string,
24
+ profile: UpdateProfile,
25
+ run: RunPreparation = runFile,
26
+ ): Promise<z.infer<typeof resultSchema>> {
27
+ stableVersionSchema.parse(version);
28
+ const result = resultSchema.parse(await runPreparer(version, profile, run));
29
+ if (result.status === "prepared" && result.targetVersion !== version) {
30
+ throw new Error("The prepared update does not match the announced version.");
31
+ }
32
+ return result;
33
+ }
34
+
35
+ async function runPreparer(argument: string, profile: UpdateProfile, run: RunPreparation): Promise<unknown> {
36
+ const environment: NodeJS.ProcessEnv = { ...process.env, AGENT_BOT_HOME: profile.home, AGENT_BOT_CONFIG: profile.configPath };
37
+ delete environment.AGENT_BOT;
38
+ // npm preparation is synchronous and can take minutes; keep it off the live worker's event loop.
39
+ const { stdout } = await run(process.execPath, [fileURLToPath(import.meta.url), argument], {
40
+ env: environment, windowsHide: true, encoding: "utf8", maxBuffer: 1_000_000,
41
+ });
42
+ return JSON.parse(String(stdout)) as unknown;
43
+ }
44
+
45
+ async function main(): Promise<void> {
46
+ if (process.argv[2] === "--check") {
47
+ requireNpmSelfUpdateInstallation();
48
+ process.stdout.write(JSON.stringify({ status: "supported" }));
49
+ return;
50
+ }
51
+ const version = stableVersionSchema.parse(process.argv[2]);
52
+ const config = loadConfig();
53
+ const prepared = await prepareSelfUpdate({ version, channel: "latest" });
54
+ if (prepared.status === "prepared") {
55
+ try {
56
+ finalizeSelfUpdatePlan(prepared.planPath, {
57
+ controlEndpoint: controlEndpoint(config.storage.sqlitePath),
58
+ databasePath: config.storage.sqlitePath,
59
+ restartService: true,
60
+ workingDirectory: process.cwd(),
61
+ reason: `Agent Bot 自动更新到 ${version}`,
62
+ });
63
+ } catch (error) {
64
+ releaseSelfUpdatePlan(prepared.planPath);
65
+ throw error;
66
+ }
67
+ }
68
+ process.stdout.write(JSON.stringify(prepared));
69
+ }
70
+
71
+ if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
72
+ void main().catch((error: unknown) => {
73
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
74
+ process.exitCode = 1;
75
+ });
76
+ }