@keyou007/agent-bot 0.1.14-alpha.1 → 0.1.14-alpha.3

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 (80) hide show
  1. package/CHANGELOG.md +19 -1
  2. package/README.md +48 -15
  3. package/README.zh.md +48 -15
  4. package/config.example.yaml +1 -1
  5. package/dist/cli/AutostartManager.js +431 -0
  6. package/dist/cli/AutostartManager.js.map +1 -0
  7. package/dist/cli/FeishuAppConfiguration.js +21 -13
  8. package/dist/cli/FeishuAppConfiguration.js.map +1 -1
  9. package/dist/cli/Initializer.js +35 -0
  10. package/dist/cli/Initializer.js.map +1 -1
  11. package/dist/cli/SelfUpdateRunner.js +552 -0
  12. package/dist/cli/SelfUpdateRunner.js.map +1 -0
  13. package/dist/cli/SelfUpdater.js +431 -0
  14. package/dist/cli/SelfUpdater.js.map +1 -0
  15. package/dist/cli/autostartOutput.js +63 -0
  16. package/dist/cli/autostartOutput.js.map +1 -0
  17. package/dist/cli/controlProtocol.js.map +1 -1
  18. package/dist/cli/help.js +94 -18
  19. package/dist/cli/help.js.map +1 -1
  20. package/dist/cli/taskGroupOptions.js +14 -11
  21. package/dist/cli/taskGroupOptions.js.map +1 -1
  22. package/dist/cli/taskListOutput.js +3 -0
  23. package/dist/cli/taskListOutput.js.map +1 -1
  24. package/dist/cli/taskTarget.js +99 -0
  25. package/dist/cli/taskTarget.js.map +1 -0
  26. package/dist/cli.js +661 -58
  27. package/dist/cli.js.map +1 -1
  28. package/dist/codex/CodexRuntime.js +8 -2
  29. package/dist/codex/CodexRuntime.js.map +1 -1
  30. package/dist/commands/CommandRouter.js +16 -1
  31. package/dist/commands/CommandRouter.js.map +1 -1
  32. package/dist/feishu/CardRenderer.js +266 -38
  33. package/dist/feishu/CardRenderer.js.map +1 -1
  34. package/dist/feishu/FeishuMarkdown.js +25 -1
  35. package/dist/feishu/FeishuMarkdown.js.map +1 -1
  36. package/dist/feishu/FeishuMessageClient.js +16 -0
  37. package/dist/feishu/FeishuMessageClient.js.map +1 -1
  38. package/dist/feishu/FeishuTurnPresenter.js +42 -6
  39. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  40. package/dist/index.js +136 -2
  41. package/dist/index.js.map +1 -1
  42. package/dist/presentation/OutboundRouter.js +7 -0
  43. package/dist/presentation/OutboundRouter.js.map +1 -1
  44. package/dist/presentation/TurnStateReducer.js +21 -7
  45. package/dist/presentation/TurnStateReducer.js.map +1 -1
  46. package/dist/proxy/ProxySessionController.js +537 -13
  47. package/dist/proxy/ProxySessionController.js.map +1 -1
  48. package/dist/state/StateStore.js +53 -0
  49. package/dist/state/StateStore.js.map +1 -1
  50. package/docs/technical-reference.md +17 -7
  51. package/docs/technical-reference.zh.md +17 -7
  52. package/npm-shrinkwrap.json +2 -2
  53. package/package.json +2 -2
  54. package/skills/agent-bot/SKILL.md +109 -188
  55. package/src/cli/AutostartManager.ts +579 -0
  56. package/src/cli/FeishuAppConfiguration.ts +26 -7
  57. package/src/cli/Initializer.ts +57 -0
  58. package/src/cli/SelfUpdateRunner.ts +817 -0
  59. package/src/cli/SelfUpdater.ts +688 -0
  60. package/src/cli/autostartOutput.ts +75 -0
  61. package/src/cli/controlProtocol.ts +119 -1
  62. package/src/cli/help.ts +94 -18
  63. package/src/cli/taskGroupOptions.ts +27 -17
  64. package/src/cli/taskListOutput.ts +10 -0
  65. package/src/cli/taskTarget.ts +168 -0
  66. package/src/cli.ts +809 -65
  67. package/src/codex/CodexRuntime.ts +8 -2
  68. package/src/commands/CommandRouter.ts +14 -1
  69. package/src/commands/commandTypes.ts +2 -0
  70. package/src/feishu/CardRenderer.ts +313 -35
  71. package/src/feishu/FeishuMarkdown.ts +27 -1
  72. package/src/feishu/FeishuMessageClient.ts +33 -0
  73. package/src/feishu/FeishuTurnPresenter.ts +43 -6
  74. package/src/feishu/types.ts +1 -0
  75. package/src/index.ts +174 -2
  76. package/src/presentation/OutboundRouter.ts +7 -0
  77. package/src/presentation/TurnStateReducer.ts +21 -7
  78. package/src/proxy/ProxySessionController.ts +661 -16
  79. package/src/runtime/types.ts +1 -0
  80. package/src/state/StateStore.ts +56 -0
@@ -355,12 +355,18 @@ export class CodexRuntime implements AgentRuntime {
355
355
 
356
356
  async closeSession(sessionId: string): Promise<void> {
357
357
  const session = this.requireSession(sessionId);
358
+ await this.archiveRemoteSession(session.remoteSessionId);
359
+ }
360
+
361
+ async archiveRemoteSession(remoteSessionId: string): Promise<void> {
358
362
  await (await this.client()).request(
359
363
  "thread/archive",
360
- { threadId: session.remoteSessionId },
364
+ { threadId: remoteSessionId },
361
365
  CONTROL_REQUEST_TIMEOUT_MS,
362
366
  );
363
- this.sessions.delete(sessionId);
367
+ for (const [localSessionId, session] of this.sessions) {
368
+ if (session.remoteSessionId === remoteSessionId) this.sessions.delete(localSessionId);
369
+ }
364
370
  }
365
371
 
366
372
  async setTitle(sessionId: string, title: string): Promise<void> {
@@ -1,8 +1,10 @@
1
1
  import type { Command } from "./commandTypes.js";
2
2
 
3
- const COMMAND_NAMES = [
3
+ export const COMMAND_NAMES = [
4
4
  "agent",
5
+ "archive",
5
6
  "dir",
7
+ "dismiss",
6
8
  "fork",
7
9
  "forkgroup",
8
10
  "goal",
@@ -28,6 +30,7 @@ const COMMAND_NAMES = [
28
30
  type CommandName = (typeof COMMAND_NAMES)[number];
29
31
 
30
32
  const COMMAND_INITIALISMS: Partial<Record<CommandName, string>> = {
33
+ dir: "di",
31
34
  forkgroup: "fg",
32
35
  newgroup: "ng",
33
36
  nosteer: "ns",
@@ -76,6 +79,13 @@ export class CommandRouter {
76
79
  return args[0] ? { type: "switch", sessionId: args[0] } : { type: "switch" };
77
80
  case "agent":
78
81
  return args[0] ? { type: "agent", agent: args[0] } : { type: "agent" };
82
+ case "archive":
83
+ rejectOptions("/archive", args);
84
+ if (args.length > 1) throw new Error("/archive 只接受一个可选的任务序号或任务 ID。");
85
+ return args[0] ? { type: "archive", sessionId: args[0] } : { type: "archive" };
86
+ case "dismiss":
87
+ if (args.length > 0) throw new Error("/dismiss 不接受参数,请在确认卡片中完成操作。");
88
+ return { type: "dismiss" };
79
89
  case "stop":
80
90
  return { type: "stop" };
81
91
  case "status":
@@ -132,6 +142,9 @@ function resolveCommandName(rawCommand: string): CommandName {
132
142
  const exact = COMMAND_NAMES.find((command) => command === input);
133
143
  if (exact) return exact;
134
144
 
145
+ const initialism = COMMAND_NAMES.find((command) => COMMAND_INITIALISMS[command] === input);
146
+ if (initialism) return initialism;
147
+
135
148
  const matches = COMMAND_NAMES.filter(
136
149
  (command) => command.startsWith(input) || COMMAND_INITIALISMS[command] === input,
137
150
  );
@@ -10,6 +10,8 @@ export type Command =
10
10
  | { type: "sessions"; searchTerm?: string }
11
11
  | { type: "switch"; sessionId?: string }
12
12
  | { type: "agent"; agent?: string }
13
+ | { type: "archive"; sessionId?: string }
14
+ | { type: "dismiss" }
13
15
  | { type: "stop" }
14
16
  | { type: "status"; sessionId?: string }
15
17
  | { type: "goal"; action: "show" }
@@ -11,7 +11,7 @@ import type {
11
11
  ToolState,
12
12
  } from "../runtime/types.js";
13
13
  import type { TurnActivity, TurnViewState, TurnViewStatus } from "../presentation/turnViewTypes.js";
14
- import { truncateMiddle, truncateText } from "../utils/markdown.js";
14
+ import { truncateText } from "../utils/markdown.js";
15
15
  import { localCardImage } from "./LocalCardImage.js";
16
16
 
17
17
  export interface StartupStatusView {
@@ -226,6 +226,13 @@ export interface ResetHistoryCardView {
226
226
  pageActions: TaskListCardAction[];
227
227
  }
228
228
 
229
+ export interface DismissGroupCardView {
230
+ contextKey: string;
231
+ sessionId: string;
232
+ taskTitle: string;
233
+ requestedBy: string;
234
+ }
235
+
229
236
  export class CardRenderer {
230
237
  private readonly thinkingCardLayout: ThinkingCardLayout;
231
238
 
@@ -853,19 +860,29 @@ export class CardRenderer {
853
860
  const elements = this.thinkingCardLayout === "timeline"
854
861
  ? renderTurnElements(state, "hidden")
855
862
  : renderGroupedTurnElements(state, "hidden");
863
+ const footerActions: TaskListCardAction[] = [];
856
864
  if (isTurnStoppable(state.status)) {
857
- elements.push({ tag: "hr" }, taskActionRow([{
865
+ footerActions.push({
858
866
  text: "Stop",
859
867
  type: "danger",
860
868
  value: { action: "turn_cancel", sessionId: state.sessionId, turnId: state.turnId },
861
- }]));
869
+ });
870
+ elements.push(
871
+ { tag: "hr" },
872
+ taskActionRow(footerActions),
873
+ );
862
874
  } else if (state.status === "completed") {
875
+ footerActions.push({
876
+ text: "Reset",
877
+ value: { action: "turn_reset", sessionId: state.sessionId, turnId: state.turnId },
878
+ });
863
879
  elements.push(
864
880
  { tag: "hr" },
865
- taskActionRow([{
866
- text: "Reset",
867
- value: { action: "turn_reset", sessionId: state.sessionId, turnId: state.turnId },
868
- }]),
881
+ taskActionRow(footerActions),
882
+ );
883
+ }
884
+ if (state.status === "completed") {
885
+ elements.push(
869
886
  {
870
887
  ...markdown("<font color='grey'>Reset 会将当前任务的对话上下文恢复到本轮完成时;不会回退本地文件。</font>"),
871
888
  text_size: "notation",
@@ -889,15 +906,26 @@ export class CardRenderer {
889
906
 
890
907
  renderActivityHistory(state: TurnViewState, requestedPage: number): Record<string, unknown> {
891
908
  const grouped = this.thinkingCardLayout === "grouped";
909
+ const activities = turnActivities(state);
910
+ const allGroups = grouped ? groupTurnActivities(activities).flatMap(splitGroupedExecutionActivity) : [];
911
+ const historyGroups = grouped
912
+ ? groupedLiveActivityPage(groupedActivityPagesFromGroups(allGroups, state.projectCwd), state.projectCwd).historyGroups
913
+ : [];
914
+ const usesHistoricalPrefixPages = historyGroups.length > 0;
892
915
  const pages = grouped
893
- ? groupedActivityPages(turnActivities(state), state.projectCwd, {
916
+ ? groupedActivityPagesFromGroups(
917
+ usesHistoricalPrefixPages ? historyGroups : allGroups,
918
+ state.projectCwd,
919
+ {
894
920
  fullActivityText: true,
895
921
  includeReasoningHistory: true,
896
- })
897
- : activityPages(turnActivities(state));
922
+ },
923
+ )
924
+ : activityPages(activities);
898
925
  if (pages.length === 0) {
899
926
  return this.renderSectionsCard("思考活动历史", [{ lines: ["没有保存到活动记录。"] }]);
900
927
  }
928
+ const totalPages = usesHistoricalPrefixPages ? pages.length + 1 : pages.length;
901
929
  const page = Math.max(0, Math.min(Math.trunc(requestedPage), pages.length - 1));
902
930
  const actions: TaskListCardAction[] = [
903
931
  ...(pages.length > 1 ? [{
@@ -908,7 +936,7 @@ export class CardRenderer {
908
936
  text: "上一页",
909
937
  value: { action: "activity_history", turnId: state.turnId, page: String(page - 1) },
910
938
  }] : []),
911
- ...(page < pages.length - 2 ? [{
939
+ ...(page < pages.length - (usesHistoricalPrefixPages ? 1 : 2) ? [{
912
940
  text: "下一页",
913
941
  value: { action: "activity_history", turnId: state.turnId, page: String(page + 1) },
914
942
  }] : []),
@@ -921,7 +949,7 @@ export class CardRenderer {
921
949
  includeReasoningHistory: true,
922
950
  })
923
951
  : renderActivities(pages[page] as TurnActivity[] | undefined ?? [], state.projectCwd, true)));
924
- return sectionCard(`思考活动历史 · ${page + 1}/${pages.length}`, elements.length > 0 ? elements : [markdown("无")]);
952
+ return sectionCard(`思考活动历史 · ${page + 1}/${totalPages}`, elements.length > 0 ? elements : [markdown("无")]);
925
953
  }
926
954
 
927
955
  renderSessionStarted(session: RuntimeSession): Record<string, unknown> {
@@ -969,6 +997,57 @@ export class CardRenderer {
969
997
  return this.baseCard("Agent Bot 状态", "blue", [markdown(status)]);
970
998
  }
971
999
 
1000
+ renderDismissGroupConfirmation(view: DismissGroupCardView): Record<string, unknown> {
1001
+ const actionValue = {
1002
+ contextKey: view.contextKey,
1003
+ sessionId: view.sessionId,
1004
+ requestedBy: view.requestedBy,
1005
+ };
1006
+ const button = (
1007
+ content: string,
1008
+ type: "default" | "danger",
1009
+ action: "group_dismiss_confirm" | "group_dismiss_keep",
1010
+ ): Record<string, unknown> => ({
1011
+ tag: "button",
1012
+ width: "fill",
1013
+ text: { tag: "plain_text", content },
1014
+ type,
1015
+ behaviors: [{
1016
+ type: "callback",
1017
+ value: { action, ...actionValue },
1018
+ }],
1019
+ });
1020
+ return compactCard("解散当前群聊", "blue", [
1021
+ markdown([
1022
+ "确定要解散当前群聊吗?",
1023
+ `当前任务「${escapeCardHtml(view.taskTitle)}」将同时归档。此操作无法撤销。`,
1024
+ ].join("\n")),
1025
+ {
1026
+ tag: "column_set",
1027
+ flex_mode: "none",
1028
+ horizontal_spacing: "8px",
1029
+ columns: [
1030
+ {
1031
+ tag: "column",
1032
+ width: "weighted",
1033
+ weight: 1,
1034
+ elements: [button("Dismiss", "danger", "group_dismiss_confirm")],
1035
+ },
1036
+ {
1037
+ tag: "column",
1038
+ width: "weighted",
1039
+ weight: 1,
1040
+ elements: [button("Keep", "default", "group_dismiss_keep")],
1041
+ },
1042
+ ],
1043
+ },
1044
+ ]);
1045
+ }
1046
+
1047
+ renderDismissGroupKept(): Record<string, unknown> {
1048
+ return compactCard("已保留当前群聊", "grey", [markdown("已取消解散群聊。")]);
1049
+ }
1050
+
972
1051
  renderSectionsCard(
973
1052
  title: string,
974
1053
  sections: CardSection[],
@@ -1160,17 +1239,21 @@ export class CardRenderer {
1160
1239
  }
1161
1240
 
1162
1241
  function renderTurnSubtitle(state: TurnViewState): string {
1163
- const elapsed = state.durationMs ?? Math.max(0, Date.now() - state.startedAt);
1164
1242
  const activityTools = turnActivities(state).filter((activity) => activity.kind === "tool").length;
1165
1243
  const totalTools = state.totalToolCount ?? activityTools;
1166
1244
  return [
1167
- `耗时 ${formatTurnDuration(elapsed)}`,
1245
+ `耗时 ${renderTurnDuration(state)}`,
1168
1246
  state.totalTokens !== undefined ? `${formatTokenCount(state.totalTokens)} tokens` : undefined,
1169
1247
  totalTools > 0 ? `${totalTools} 个工具` : undefined,
1170
1248
  state.fileSummary.length > 0 ? `${state.fileSummary.length} 个文件` : undefined,
1171
1249
  ].filter(Boolean).join(" · ");
1172
1250
  }
1173
1251
 
1252
+ function renderTurnDuration(state: TurnViewState): string {
1253
+ const endedAt = state.completedAt ?? Date.now();
1254
+ return formatTurnDuration(state.durationMs ?? Math.max(0, endedAt - state.startedAt));
1255
+ }
1256
+
1174
1257
  function isTurnStoppable(status: TurnViewStatus): boolean {
1175
1258
  return status === "running" || status === "tool_running" || status === "waiting_for_approval";
1176
1259
  }
@@ -1249,24 +1332,24 @@ function renderGroupedTurnElements(
1249
1332
  const elements: Record<string, unknown>[] = [];
1250
1333
  const allActivities = turnActivities(state);
1251
1334
  const livePages = groupedActivityPages(allActivities, state.projectCwd);
1252
- const historyPages = groupedActivityPages(allActivities, state.projectCwd, {
1335
+ const visible = groupedLiveActivityPage(livePages, state.projectCwd);
1336
+ const historyPages = groupedActivityPagesFromGroups(visible.historyGroups, state.projectCwd, {
1253
1337
  fullActivityText: true,
1254
1338
  includeReasoningHistory: true,
1255
1339
  });
1256
- const visibleGroups = livePages.at(-1) ?? [];
1257
1340
  if (state.plan.length > 0) elements.push(planPanel(state.plan));
1258
- if (historyPages.length > 1) {
1341
+ if (historyPages.length > 0) {
1259
1342
  elements.push(taskActionRow([{
1260
- text: `查看历史思考(共 ${historyPages.length} 页)`,
1343
+ text: `查看历史思考(共 ${historyPages.length + 1} 页)`,
1261
1344
  value: {
1262
1345
  action: "activity_history",
1263
1346
  turnId: state.turnId,
1264
- page: String(historyPages.length - 2),
1347
+ page: String(historyPages.length - 1),
1265
1348
  },
1266
1349
  }]));
1267
1350
  }
1268
- if (state.activitiesTruncated || livePages.length > 1 || historyPages.length > 1) elements.push(markdown("…"));
1269
- elements.push(...renderGroupedActivityGroups(visibleGroups, state.projectCwd));
1351
+ if (state.activitiesTruncated && visible.groups[0]?.kind !== "gap") elements.push(markdown("…"));
1352
+ elements.push(...renderGroupedActivityGroups(visible.groups, state.projectCwd));
1270
1353
  if (state.fileSummary.length > 0) elements.push(fileSummaryPanel(state));
1271
1354
 
1272
1355
  if (state.approval) {
@@ -1407,6 +1490,7 @@ function renderActivities(
1407
1490
 
1408
1491
  type GroupedTurnActivity =
1409
1492
  | { kind: "activity"; activity: TurnActivity }
1493
+ | { kind: "gap"; id: string }
1410
1494
  | {
1411
1495
  kind: "execution";
1412
1496
  id: string;
@@ -1424,6 +1508,7 @@ function renderGroupedActivityGroups(
1424
1508
  } = {},
1425
1509
  ): Record<string, unknown>[] {
1426
1510
  return groups.flatMap((group) => {
1511
+ if (group.kind === "gap") return [markdown("…")];
1427
1512
  if (group.kind === "activity") {
1428
1513
  return renderActivity(group.activity, projectCwd, options.fullActivityText === true);
1429
1514
  }
@@ -1500,7 +1585,7 @@ function executionActivityPanel(
1500
1585
  {
1501
1586
  elementId: executionActivityPanelElementId(group.id),
1502
1587
  expanded: false,
1503
- borderColor: status === "failed" ? "red" : status === "running" ? "blue" : "grey",
1588
+ borderColor: status === "running" ? "blue" : "grey",
1504
1589
  compact: true,
1505
1590
  },
1506
1591
  );
@@ -1508,7 +1593,6 @@ function executionActivityPanel(
1508
1593
 
1509
1594
  function executionActivityStatus(tools: ToolState[]): ToolState["status"] {
1510
1595
  if (tools.some((tool) => tool.status === "running")) return "running";
1511
- if (tools.some((tool) => tool.status === "failed")) return "failed";
1512
1596
  return "completed";
1513
1597
  }
1514
1598
 
@@ -1516,7 +1600,7 @@ function executionActivityTitle(
1516
1600
  group: Extract<GroupedTurnActivity, { kind: "execution" }>,
1517
1601
  status: ToolState["status"],
1518
1602
  ): string {
1519
- const icon = status === "running" ? "⏳" : status === "failed" ? "❌" : "💭";
1603
+ const icon = status === "running" ? "⏳" : "💭";
1520
1604
  const latestReasoning = group.latestReasoning?.text
1521
1605
  ? removeMarkdownBold(group.latestReasoning.text).replace(/\s+/g, " ").trim()
1522
1606
  : "";
@@ -1524,9 +1608,7 @@ function executionActivityTitle(
1524
1608
  ? truncateText(latestReasoning, 90)
1525
1609
  : status === "running"
1526
1610
  ? "正在执行工具"
1527
- : status === "failed"
1528
- ? "工具执行失败"
1529
- : "已执行工具";
1611
+ : "已执行工具";
1530
1612
  return `${icon} ${summary} · ${group.tools.length} 个工具`;
1531
1613
  }
1532
1614
 
@@ -1559,6 +1641,7 @@ const ACTIVITIES_PER_PAGE = 40;
1559
1641
  const GROUPED_TOOLS_PER_PANEL = 8;
1560
1642
  const GROUPED_PAGE_ACTIVITY_BYTES = 24 * 1024;
1561
1643
  const GROUPED_PAGE_ACTIVITY_COMPONENTS = 160;
1644
+ const PINNED_LIVE_COMMENTARIES = 3;
1562
1645
  const MAX_LIVE_ASSISTANT_TEXT = 2_000;
1563
1646
  const ACTIVITY_TEXT_CHUNK = 2_500;
1564
1647
 
@@ -1583,6 +1666,17 @@ function groupedActivityPages(
1583
1666
  } = {},
1584
1667
  ): GroupedTurnActivity[][] {
1585
1668
  const groups = groupTurnActivities(activities).flatMap(splitGroupedExecutionActivity);
1669
+ return groupedActivityPagesFromGroups(groups, projectCwd, renderOptions);
1670
+ }
1671
+
1672
+ function groupedActivityPagesFromGroups(
1673
+ groups: GroupedTurnActivity[],
1674
+ projectCwd?: string,
1675
+ renderOptions: {
1676
+ fullActivityText?: boolean;
1677
+ includeReasoningHistory?: boolean;
1678
+ } = {},
1679
+ ): GroupedTurnActivity[][] {
1586
1680
  if (groups.length === 0) return [];
1587
1681
 
1588
1682
  const newestFirst: GroupedTurnActivity[][] = [];
@@ -1613,6 +1707,82 @@ function groupedActivityPageFits(
1613
1707
  && countCardComponents(elements) <= GROUPED_PAGE_ACTIVITY_COMPONENTS;
1614
1708
  }
1615
1709
 
1710
+ interface LiveGroupedActivityPage {
1711
+ groups: GroupedTurnActivity[];
1712
+ historyGroups: GroupedTurnActivity[];
1713
+ }
1714
+
1715
+ function groupedLiveActivityPage(
1716
+ pages: GroupedTurnActivity[][],
1717
+ projectCwd?: string,
1718
+ ): LiveGroupedActivityPage {
1719
+ const latest = pages.at(-1) ?? [];
1720
+ if (pages.length <= 1) return { groups: latest, historyGroups: [] };
1721
+
1722
+ const groups = pages.flat();
1723
+ const latestStart = groups.length - latest.length;
1724
+ const commentaryIndexes = groups
1725
+ .map((group, index) => isCommentaryGroup(group) ? index : -1)
1726
+ .filter((index) => index >= 0);
1727
+ if (commentaryIndexes.length === 0) {
1728
+ return { groups: latest, historyGroups: groups.slice(0, latestStart) };
1729
+ }
1730
+
1731
+ const selected = new Set<number>();
1732
+ const pinnedCommentaries = commentaryIndexes.slice(0, PINNED_LIVE_COMMENTARIES);
1733
+ for (const index of pinnedCommentaries) selected.add(index);
1734
+ const latestCommentary = commentaryIndexes.at(-1)!;
1735
+ for (let index = latestCommentary; index < groups.length; index += 1) selected.add(index);
1736
+
1737
+ const renderSelection = (selection: ReadonlySet<number>): LiveGroupedActivityPage => ({
1738
+ groups: groupsWithGaps(groups, selection),
1739
+ historyGroups: groups.filter((_group, index) => index < latestCommentary || !selection.has(index)),
1740
+ });
1741
+ const fits = (selection: ReadonlySet<number>): boolean => {
1742
+ const view = renderSelection(selection);
1743
+ return groupedActivityPageFits(view.groups, projectCwd, {});
1744
+ };
1745
+
1746
+ if (!fits(selected)) {
1747
+ const latestExecutions = [...selected]
1748
+ .filter((index) => index > latestCommentary && groups[index]?.kind === "execution")
1749
+ .sort((left, right) => left - right);
1750
+ const newestExecution = latestExecutions.at(-1);
1751
+ for (const index of latestExecutions) {
1752
+ if (fits(selected) || index === newestExecution) break;
1753
+ selected.delete(index);
1754
+ }
1755
+ }
1756
+
1757
+ return renderSelection(selected);
1758
+ }
1759
+
1760
+ function isCommentaryGroup(group: GroupedTurnActivity): boolean {
1761
+ return group.kind === "activity" && isCommentaryActivity(group.activity);
1762
+ }
1763
+
1764
+ function groupsWithGaps(
1765
+ groups: GroupedTurnActivity[],
1766
+ selected: ReadonlySet<number>,
1767
+ ): GroupedTurnActivity[] {
1768
+ const indexes = [...selected].sort((left, right) => left - right);
1769
+ const visible: GroupedTurnActivity[] = [];
1770
+ const append = (group: GroupedTurnActivity): void => {
1771
+ if (group.kind === "gap" && visible.at(-1)?.kind === "gap") return;
1772
+ visible.push(group);
1773
+ };
1774
+ let previous = -1;
1775
+ for (const index of indexes) {
1776
+ if (previous >= 0 && index > previous + 1) {
1777
+ append({ kind: "gap", id: `gap:${previous}:${index}` });
1778
+ }
1779
+ const group = groups[index];
1780
+ if (group) append(group);
1781
+ previous = index;
1782
+ }
1783
+ return visible;
1784
+ }
1785
+
1616
1786
  function countCardComponents(value: unknown): number {
1617
1787
  if (Array.isArray(value)) {
1618
1788
  return value.reduce((total, entry) => total + countCardComponents(entry), 0);
@@ -1791,13 +1961,22 @@ function renderToolDetails(tool: ToolState, projectCwd?: string): string {
1791
1961
  const result = tool.error ?? tool.output ?? fileSummary;
1792
1962
  const normalizedCommand = stripAnsi(command).trim();
1793
1963
  const displayCommand = tool.kind === "command"
1794
- ? unwrapPowerShellCommand(normalizedCommand) ?? normalizedCommand
1964
+ ? unwrapShellCommand(normalizedCommand) ?? normalizedCommand
1795
1965
  : normalizedCommand;
1796
- const commandText = truncateText(displayCommand, 800);
1797
- const resultText = result ? truncateMiddle(stripAnsi(result).trim(), 1_200) : undefined;
1966
+ const commandText = truncateText(displayCommand, 600);
1967
+ const resultText = result ? truncateToolResult(stripAnsi(result).trim()) : undefined;
1798
1968
  return codeBlock([`$ ${commandText}`, resultText].filter((part): part is string => part !== undefined).join("\n"), 2_003);
1799
1969
  }
1800
1970
 
1971
+ function truncateToolResult(result: string): string {
1972
+ const maxLength = 900;
1973
+ const marker = "\n...\n";
1974
+ const tailLength = 600;
1975
+ if (result.length <= maxLength) return result;
1976
+ const headLength = maxLength - marker.length - tailLength;
1977
+ return `${result.slice(0, headLength)}${marker}${result.slice(-tailLength)}`;
1978
+ }
1979
+
1801
1980
  function displayFilePath(filePath: string, projectCwd?: string): string {
1802
1981
  if (!projectCwd) return filePath;
1803
1982
  const pathApi = usesWindowsPaths(projectCwd, filePath) ? path.win32 : path;
@@ -1826,7 +2005,7 @@ function toolPanelTitle(tool: ToolState): string {
1826
2005
  const command = stripAnsi(tool.command ?? tool.title).trim();
1827
2006
  const meaningfulCommand = tool.kind === "web_search"
1828
2007
  ? tool.title
1829
- : unwrapPowerShellCommand(command) ?? tool.title;
2008
+ : unwrapShellCommand(command) ?? tool.title;
1830
2009
  const duration = toolDuration(tool);
1831
2010
  const prefix = `${icon} `;
1832
2011
  const suffix = duration ? ` · ${duration}` : "";
@@ -1875,6 +2054,10 @@ function formatTurnDuration(durationMs: number): string {
1875
2054
  return `${seconds}s`;
1876
2055
  }
1877
2056
 
2057
+ function unwrapShellCommand(command: string): string | undefined {
2058
+ return unwrapPowerShellCommand(command) ?? unwrapPosixShellCommand(command);
2059
+ }
2060
+
1878
2061
  function unwrapPowerShellCommand(command: string): string | undefined {
1879
2062
  const executable = command.match(
1880
2063
  /^(?:"[^"]*(?:pwsh|powershell)\.exe"|(?:\S*[\\/])?(?:pwsh|powershell)(?:\.exe)?)(?=\s|$)/i,
@@ -1885,6 +2068,71 @@ function unwrapPowerShellCommand(command: string): string | undefined {
1885
2068
  if (!commandFlag) return undefined;
1886
2069
  const payload = args.slice(commandFlag.index + commandFlag[0].length).trim();
1887
2070
  if (!payload) return undefined;
2071
+ return unwrapQuotedPayload(payload);
2072
+ }
2073
+
2074
+ function unwrapPosixShellCommand(command: string): string | undefined {
2075
+ let executable = readCommandWord(command, 0);
2076
+ if (!executable) return undefined;
2077
+
2078
+ if (posixExecutableName(executable.value) === "env") {
2079
+ let cursor = executable.end;
2080
+ while (true) {
2081
+ const candidate = readCommandWord(command, cursor);
2082
+ if (!candidate) return undefined;
2083
+ const name = posixExecutableName(candidate.value);
2084
+ if (isPosixShell(name)) {
2085
+ executable = candidate;
2086
+ break;
2087
+ }
2088
+ if (!candidate.value.startsWith("-") && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(candidate.value)) {
2089
+ return undefined;
2090
+ }
2091
+ cursor = candidate.end;
2092
+ }
2093
+ } else if (!isPosixShell(posixExecutableName(executable.value))) {
2094
+ return undefined;
2095
+ }
2096
+
2097
+ const args = command.slice(executable.end);
2098
+ const commandFlag = /(?:^|\s)(?:-[A-Za-z]*c[A-Za-z]*|--command)(?:\s+|=)/.exec(args);
2099
+ if (!commandFlag) return undefined;
2100
+ const payload = args.slice(commandFlag.index + commandFlag[0].length).trim();
2101
+ if (!payload) return undefined;
2102
+ return unwrapQuotedPayload(payload);
2103
+ }
2104
+
2105
+ function readCommandWord(command: string, start: number): { value: string; end: number } | undefined {
2106
+ let cursor = start;
2107
+ while (cursor < command.length && /\s/.test(command[cursor] ?? "")) cursor += 1;
2108
+ if (cursor >= command.length) return undefined;
2109
+
2110
+ const quote = command[cursor];
2111
+ if (quote === "\"" || quote === "'") {
2112
+ const endQuote = command.indexOf(quote, cursor + 1);
2113
+ if (endQuote < 0) return undefined;
2114
+ return { value: command.slice(cursor + 1, endQuote), end: endQuote + 1 };
2115
+ }
2116
+
2117
+ const word = /^\S+/.exec(command.slice(cursor));
2118
+ if (!word) return undefined;
2119
+ return { value: word[0], end: cursor + word[0].length };
2120
+ }
2121
+
2122
+ function posixExecutableName(executable: string): string {
2123
+ return executable.replaceAll("\\", "/").split("/").at(-1)?.toLowerCase() ?? "";
2124
+ }
2125
+
2126
+ function isPosixShell(executable: string): boolean {
2127
+ return executable === "sh"
2128
+ || executable === "bash"
2129
+ || executable === "zsh"
2130
+ || executable === "dash"
2131
+ || executable === "ksh"
2132
+ || executable === "fish";
2133
+ }
2134
+
2135
+ function unwrapQuotedPayload(payload: string): string {
1888
2136
  const quote = payload[0];
1889
2137
  if ((quote === "\"" || quote === "'") && payload.at(-1) === quote) return payload.slice(1, -1).trim();
1890
2138
  return payload;
@@ -2316,10 +2564,12 @@ function turnCard(
2316
2564
  tag: "plain_text",
2317
2565
  content: title,
2318
2566
  },
2319
- subtitle: {
2320
- tag: "plain_text",
2321
- content: subtitle,
2322
- },
2567
+ ...(subtitle ? {
2568
+ subtitle: {
2569
+ tag: "plain_text",
2570
+ content: subtitle,
2571
+ },
2572
+ } : {}),
2323
2573
  padding: "12px 12px 12px 12px",
2324
2574
  },
2325
2575
  body: {
@@ -2360,6 +2610,34 @@ function sectionCard(
2360
2610
  };
2361
2611
  }
2362
2612
 
2613
+ function compactCard(
2614
+ title: string,
2615
+ template: string,
2616
+ elements: Record<string, unknown>[],
2617
+ ): Record<string, unknown> {
2618
+ return {
2619
+ schema: "2.0",
2620
+ config: {
2621
+ update_multi: true,
2622
+ width_mode: "compact",
2623
+ },
2624
+ header: {
2625
+ template,
2626
+ title: {
2627
+ tag: "plain_text",
2628
+ content: title,
2629
+ },
2630
+ padding: "12px 12px 12px 12px",
2631
+ },
2632
+ body: {
2633
+ direction: "vertical",
2634
+ vertical_spacing: "12px",
2635
+ padding: "12px 12px 12px 12px",
2636
+ elements,
2637
+ },
2638
+ };
2639
+ }
2640
+
2363
2641
  function formatUpdate(update: Record<string, JsonValue>): string {
2364
2642
  const updateType = update.sessionUpdate;
2365
2643
  if (updateType === "agent_message_chunk" && isObject(update.content)) {
@@ -6,6 +6,8 @@ interface FenceState {
6
6
  }
7
7
 
8
8
  const FENCE_OPENER = /^([ \t]*)((?:`{3,})|(?:~{3,}))(.*)$/;
9
+ const MARKDOWN_LINK = /(!?)\[([^\]\r\n]*)\]\((<?[^)\r\n]+>?)\)/g;
10
+ const LOCAL_FILE_LINE_REFERENCE = /(:\d+(?::\d+)?)$/;
9
11
 
10
12
  export function normalizeFeishuMarkdown(markdown: string): string {
11
13
  const lines = markdown.match(/[^\r\n]*(?:\r\n|\n)|[^\r\n]+$/g) ?? [];
@@ -17,7 +19,7 @@ export function normalizeFeishuMarkdown(markdown: string): string {
17
19
 
18
20
  if (!fence) {
19
21
  const opener = FENCE_OPENER.exec(body);
20
- if (!opener?.[2]) return line;
22
+ if (!opener?.[2]) return `${includeLocalFileLineReferences(body)}${ending}`;
21
23
 
22
24
  const indent = opener[1] ?? "";
23
25
  const delimiter = opener[2];
@@ -38,6 +40,30 @@ export function normalizeFeishuMarkdown(markdown: string): string {
38
40
  }).join("");
39
41
  }
40
42
 
43
+ function includeLocalFileLineReferences(markdown: string): string {
44
+ return markdown.replace(MARKDOWN_LINK, (link, imageMarker: string, label: string, rawTarget: string) => {
45
+ if (imageMarker) return link;
46
+
47
+ const target = unwrapMarkdownTarget(rawTarget.trim());
48
+ if (!isLocalFileTarget(target)) return link;
49
+
50
+ const reference = LOCAL_FILE_LINE_REFERENCE.exec(target)?.[1];
51
+ if (!reference || label.endsWith(reference)) return link;
52
+ return `[${label}${reference}](${rawTarget})`;
53
+ });
54
+ }
55
+
56
+ function unwrapMarkdownTarget(target: string): string {
57
+ return target.startsWith("<") && target.endsWith(">") ? target.slice(1, -1) : target;
58
+ }
59
+
60
+ function isLocalFileTarget(target: string): boolean {
61
+ return /^file:\/\//i.test(target)
62
+ || /^\/?[a-z]:[\\/]/i.test(target)
63
+ || /^\\\\/.test(target)
64
+ || /^\/(?!\/)/.test(target);
65
+ }
66
+
41
67
  function isFenceCloser(line: string, fence: FenceState): boolean {
42
68
  const match = /^[ \t]*(`+|~+)[ \t]*$/.exec(line);
43
69
  const delimiter = match?.[1];