pilotswarm 0.5.62 → 0.5.64

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 (93) hide show
  1. package/mcp/dist/src/server.d.ts.map +1 -1
  2. package/mcp/dist/src/server.js +2 -0
  3. package/mcp/dist/src/server.js.map +1 -1
  4. package/mcp/dist/src/tools/features.d.ts +4 -0
  5. package/mcp/dist/src/tools/features.d.ts.map +1 -0
  6. package/mcp/dist/src/tools/features.js +35 -0
  7. package/mcp/dist/src/tools/features.js.map +1 -0
  8. package/package.json +2 -2
  9. package/tui/src/node-sdk-transport.js +13 -0
  10. package/ui/core/src/controller.js +330 -14
  11. package/ui/core/src/history.js +89 -14
  12. package/ui/core/src/moa.js +9 -0
  13. package/ui/core/src/native-tasks.js +197 -0
  14. package/ui/core/src/question-display.js +13 -0
  15. package/ui/core/src/reducer.js +31 -7
  16. package/ui/core/src/selectors.js +46 -13
  17. package/ui/core/src/session-errors.js +13 -6
  18. package/ui/core/src/session-warning.js +14 -0
  19. package/ui/core/src/state.js +7 -2
  20. package/ui/react/src/components.js +2 -1
  21. package/ui/react/src/feature-flags-panel.js +186 -0
  22. package/ui/react/src/native-task-card.js +59 -0
  23. package/ui/react/src/web-app.js +173 -33
  24. package/web/dist/assets/{abnfDiagram-VRR7QNED-DAiG41W-.js → abnfDiagram-VRR7QNED-gIPHF2gV.js} +1 -1
  25. package/web/dist/assets/{arc-CN_GY3DG.js → arc-DMW_q5Ml.js} +1 -1
  26. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-DD9qiBOj.js → architectureDiagram-ZJ3FMSHR-DAKsrxcB.js} +1 -1
  27. package/web/dist/assets/{blockDiagram-677ZJIJ3-CZg2R8i6.js → blockDiagram-677ZJIJ3-BUjSLpP3.js} +1 -1
  28. package/web/dist/assets/{c4Diagram-LMCZKHZV-DuZxUZYd.js → c4Diagram-LMCZKHZV-Dw2nPoes.js} +1 -1
  29. package/web/dist/assets/channel-CcOa1LtY.js +1 -0
  30. package/web/dist/assets/{chunk-2Q5K7J3B-BX7lDvmf.js → chunk-2Q5K7J3B-BAL28Rv0.js} +1 -1
  31. package/web/dist/assets/{chunk-32BRIVSS-COupwzAw.js → chunk-32BRIVSS-DDlt9K8s.js} +1 -1
  32. package/web/dist/assets/{chunk-5VM5RSS4-C_IyERib.js → chunk-5VM5RSS4-BLQGJGoP.js} +1 -1
  33. package/web/dist/assets/{chunk-EX3LRPZG-CyfIuYKn.js → chunk-EX3LRPZG-XFMb3HRy.js} +1 -1
  34. package/web/dist/assets/{chunk-JWPE2WC7-Cj5lvEfN.js → chunk-JWPE2WC7-DWnKTQ72.js} +1 -1
  35. package/web/dist/assets/{chunk-MOJQB5TN-l907LpMA.js → chunk-MOJQB5TN-MltzhnwP.js} +1 -1
  36. package/web/dist/assets/{chunk-RYQCIY6F-PLru1fOZ.js → chunk-RYQCIY6F-Ce3q0-mJ.js} +1 -1
  37. package/web/dist/assets/{chunk-V7JOEXUC-C6GCxiE5.js → chunk-V7JOEXUC-ThXLYY88.js} +1 -1
  38. package/web/dist/assets/{chunk-VR4S4FIN-BGelrIxQ.js → chunk-VR4S4FIN-DbhAykiW.js} +1 -1
  39. package/web/dist/assets/{chunk-XXDRQBXY-BmJstjEv.js → chunk-XXDRQBXY-BpkNB0mD.js} +1 -1
  40. package/web/dist/assets/classDiagram-OUVF2IWQ-B2VhHRSV.js +1 -0
  41. package/web/dist/assets/classDiagram-v2-EOCWNBFH-B2VhHRSV.js +1 -0
  42. package/web/dist/assets/{cose-bilkent-JH36ORCC-CRabxOOK.js → cose-bilkent-JH36ORCC-rctqAPvj.js} +1 -1
  43. package/web/dist/assets/{cynefin-VYW2F7L2-CminxBoG.js → cynefin-VYW2F7L2-Dg1cltg6.js} +1 -1
  44. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-yBazsQf4.js → cynefinDiagram-TSTJHNR4-Ci23Sv4Z.js} +1 -1
  45. package/web/dist/assets/{dagre-VKFMJZFB-DcZuZV2n.js → dagre-VKFMJZFB-BpWTGhpv.js} +1 -1
  46. package/web/dist/assets/{diagram-FQU43EPY-CHoFzsw6.js → diagram-FQU43EPY-B69JpiAe.js} +1 -1
  47. package/web/dist/assets/{diagram-G47NLZAW-B8leqMRl.js → diagram-G47NLZAW-BHMk1TOO.js} +1 -1
  48. package/web/dist/assets/{diagram-NH7WQ7WH-DNoVxpa3.js → diagram-NH7WQ7WH-CtvZIIsk.js} +1 -1
  49. package/web/dist/assets/{diagram-OA4YK3LP-gC3Q2X6-.js → diagram-OA4YK3LP-BFvKh800.js} +1 -1
  50. package/web/dist/assets/{diagram-WEI45ONY-CABqyjDl.js → diagram-WEI45ONY-CgB8sWPe.js} +1 -1
  51. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-cSguXaDC.js → ebnfDiagram-CCIWWBDH-DyqPNydM.js} +1 -1
  52. package/web/dist/assets/{erDiagram-Q63AITRT-CoWX37V3.js → erDiagram-Q63AITRT-CwpvZQ3Q.js} +1 -1
  53. package/web/dist/assets/{flowDiagram-23GEKE2U-I9DHYjhG.js → flowDiagram-23GEKE2U-EBKSKxfC.js} +1 -1
  54. package/web/dist/assets/{ganttDiagram-NO4QXBWP-DaFnfYTt.js → ganttDiagram-NO4QXBWP-q5tMPAuC.js} +1 -1
  55. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-CwiXVM_O.js → gitGraphDiagram-IHSO6WYX-DCRbi4KE.js} +1 -1
  56. package/web/dist/assets/index-BfF7e08d.js +24 -0
  57. package/web/dist/assets/index-uhc851H3.css +1 -0
  58. package/web/dist/assets/{infoDiagram-FWYZ7A6U-DtzIrIpW.js → infoDiagram-FWYZ7A6U-Bex5pSIX.js} +1 -1
  59. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-B9nk829n.js → ishikawaDiagram-FXEZZL3T-Dj0li_TY.js} +1 -1
  60. package/web/dist/assets/{journeyDiagram-5HDEW3XC-xLaPm-ow.js → journeyDiagram-5HDEW3XC-BtEGEeVr.js} +1 -1
  61. package/web/dist/assets/{kanban-definition-HUTT4EX6-BnDqLYLC.js → kanban-definition-HUTT4EX6-BTFfZvYt.js} +1 -1
  62. package/web/dist/assets/{linear-Djc7nVo8.js → linear-C3SQCsip.js} +1 -1
  63. package/web/dist/assets/{mermaid.core-B5elhUtV.js → mermaid.core-DALDJDu8.js} +4 -4
  64. package/web/dist/assets/{mindmap-definition-LN4V7U3C-N-B7EJGs.js → mindmap-definition-LN4V7U3C-BZf3uw0u.js} +1 -1
  65. package/web/dist/assets/{papaparse.min-BqM-LJkq.js → papaparse.min-Bh9MZ-fk.js} +1 -1
  66. package/web/dist/assets/{pegDiagram-2B236MQR-BIollSMB.js → pegDiagram-2B236MQR-PuOleiMg.js} +1 -1
  67. package/web/dist/assets/{pieDiagram-ENE6RG2P-DkZY6iCd.js → pieDiagram-ENE6RG2P-CcFORUaW.js} +1 -1
  68. package/web/dist/assets/pilotswarm-B9rLtXpI.js +109 -0
  69. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-iUDmQIyL.js → quadrantDiagram-ABIIQ3AL-CEm0mp4y.js} +1 -1
  70. package/web/dist/assets/{railroadDiagram-RFXS5EU6-BSCGJgV7.js → railroadDiagram-RFXS5EU6-DeH-uX0_.js} +1 -1
  71. package/web/dist/assets/{requirementDiagram-TGXJPOKE-9yqcGRUA.js → requirementDiagram-TGXJPOKE-CMbJVMc1.js} +1 -1
  72. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-SPh1plT9.js → sankeyDiagram-HTMAVEWB-C9gL5w_b.js} +1 -1
  73. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-C0nv5x71.js → sequenceDiagram-DBY2YBRQ-BUo8TT74.js} +1 -1
  74. package/web/dist/assets/{sizeCapture-X5ZJPWSS-DMXfUt1o.js → sizeCapture-X5ZJPWSS-C5lqG0ei.js} +1 -1
  75. package/web/dist/assets/{stateDiagram-2N3HPSRC-vjhtquua.js → stateDiagram-2N3HPSRC-DyeISrEU.js} +1 -1
  76. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-5FZbaloZ.js +1 -0
  77. package/web/dist/assets/{swimlanes-5IMT3BWC-Bqewmkt5.js → swimlanes-5IMT3BWC-DawuBk_a.js} +2 -2
  78. package/web/dist/assets/swimlanesDiagram-G3AALYLV-DL8PgtmM.js +8 -0
  79. package/web/dist/assets/{timeline-definition-FHXFAJF6-DYHsdXUL.js → timeline-definition-FHXFAJF6-BZJ2YJcm.js} +1 -1
  80. package/web/dist/assets/{vennDiagram-L72KCM5P-Cb7cOHSa.js → vennDiagram-L72KCM5P-BMXBVCfe.js} +1 -1
  81. package/web/dist/assets/{wardleyDiagram-EHGQE667-BHpO1byL.js → wardleyDiagram-EHGQE667-DWedKsrQ.js} +1 -1
  82. package/web/dist/assets/{xychartDiagram-FW5EYKEG-DHDvGPtH.js → xychartDiagram-FW5EYKEG--XdfYceC.js} +1 -1
  83. package/web/dist/index.html +3 -3
  84. package/web/runtime.js +19 -0
  85. package/web/server.js +2 -2
  86. package/web/dist/assets/channel-B50-C7oJ.js +0 -1
  87. package/web/dist/assets/classDiagram-OUVF2IWQ-CIJlKS0m.js +0 -1
  88. package/web/dist/assets/classDiagram-v2-EOCWNBFH-CIJlKS0m.js +0 -1
  89. package/web/dist/assets/index-CHlrgp0s.css +0 -1
  90. package/web/dist/assets/index-bf7RCrAf.js +0 -24
  91. package/web/dist/assets/pilotswarm-Di5s6c69.js +0 -105
  92. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-CYIeYmPN.js +0 -1
  93. package/web/dist/assets/swimlanesDiagram-G3AALYLV-BeKhH9C9.js +0 -8
@@ -2,6 +2,8 @@ import { formatHumanDurationSeconds, formatTimestamp, shortModelName, shortSessi
2
2
  import { isCanvasActionContent, parseCanvasActionContent } from "./canvas-actions.js";
3
3
  import { formatCompactionActivityRuns } from "./context-usage.js";
4
4
  import { canonicalSystemTitle } from "./system-titles.js";
5
+ import { matchesSessionError } from "./session-warning.js";
6
+ import { appendNativeTaskEvent } from "./native-tasks.js";
5
7
  import { buildSessionWarning } from "./session-errors.js";
6
8
  import { appendChatCall, CHAT_CALL_EVENT_TYPES } from "./chat-activity.js";
7
9
 
@@ -19,13 +21,20 @@ export const HISTORY_EVENT_LIMIT_STEPS = [
19
21
  // load transcript pages instead of raw-stream pages.
20
22
  export const CHAT_HISTORY_EVENT_TYPES = [
21
23
  ...CHAT_CALL_EVENT_TYPES,
22
- "session.error",
23
24
  "user.message",
24
25
  "assistant.message",
25
26
  // Needed to distinguish interim assistant output from the final answer
26
27
  // when an older transcript page is loaded.
27
28
  "session.turn_completed",
28
29
  "session.turn_stopped",
30
+ // Warnings belong at the error's position in the transcript, including
31
+ // after recovery and when paging backward through a noisy session.
32
+ "session.error",
33
+ "subagent.started",
34
+ "subagent.configured",
35
+ "subagent.completed",
36
+ "subagent.failed",
37
+ "native.task_updated",
29
38
  "system.message",
30
39
  // Session regeneration boundary — rendered as an inline epoch divider in
31
40
  // the transcript, so it must survive backward chat-history paging.
@@ -90,6 +99,63 @@ function buildRegenFailedItem(event) {
90
99
  };
91
100
  }
92
101
 
102
+ function buildSessionWarningItem(event) {
103
+ const completed = event.eventType === "session.turn_completed" && event.data?.resultType === "error";
104
+ if (event.eventType !== "session.error" && !completed) return null;
105
+ const warning = buildSessionWarning(event);
106
+ if (!warning) return null;
107
+ return {
108
+ ...warning,
109
+ errorText: warning.text,
110
+ sourceEventType: event.eventType,
111
+ ...(completed ? { turnCompletedSeq: event.seq } : {}),
112
+ time: formatTimestamp(event.createdAt),
113
+ };
114
+ }
115
+
116
+ function appendSessionWarning(chat, event) {
117
+ const warning = buildSessionWarningItem(event);
118
+ if (!warning) return;
119
+ if (chat.some((item) => item.id === warning.id
120
+ || (item.turnCompletedSeq != null && item.turnCompletedSeq === event.seq))) return;
121
+ // Tool disclosures can arrive between the SDK error and the failed-turn
122
+ // summary. They do not start a different conversation/failure episode.
123
+ const previousIndex = chat.findLastIndex(item => item.kind !== "chat-call" && item.kind !== "native-task-group");
124
+ const previous = chat[previousIndex];
125
+ // A runtime error and its failed-turn summary can describe one failure.
126
+ // Retain the original anchor; subsequent failed turns remain distinct.
127
+ if (previous?.kind === "session-warning"
128
+ && previous.sourceEventType === "session.error"
129
+ && previous.turnCompletedSeq == null
130
+ && event.eventType === "session.turn_completed"
131
+ && matchesSessionError(previous.errorText, warning.errorText)) {
132
+ chat[previousIndex] = { ...previous, turnCompletedSeq: event.seq };
133
+ return;
134
+ }
135
+ chat.push(warning);
136
+ }
137
+
138
+ const parentChatCallTypes = new Set(CHAT_CALL_EVENT_TYPES);
139
+ function appendParentChatCall(chat, event) {
140
+ if (!parentChatCallTypes.has(event?.eventType)) return;
141
+ const data = event?.data || {};
142
+ // Native tasks have their own abridged disclosure. Generic call cards
143
+ // would duplicate them and reveal the full native prompt/arguments.
144
+ const name = data.toolName || data.name;
145
+ const namespace = `${event.sessionId}:${data.durableSessionId || ""}`;
146
+ const keys = [data.toolCallId && `call:${data.toolCallId}`, data.requestId && `request:${data.requestId}`].filter(Boolean);
147
+ // An empty diagnostic completion has neither a call to update nor useful
148
+ // content. Keep it in Activity without evicting real conversation cards.
149
+ const hasContent = name || data.arguments != null || data.args != null || data.result != null || data.output != null
150
+ || data.error || data.partialOutput || data.progressMessage || data.childSessionId || data.agentId || data.task;
151
+ if (!keys.length && !hasContent) return;
152
+ const parentCall = !name && chat.some(item => item.kind === "chat-call" && item.namespace === namespace
153
+ && item.callKeys.some(key => keys.includes(key)));
154
+ const nativeCall = name === "task" || Boolean(!name && !parentCall && data.toolCallId && chat.some(item =>
155
+ item.kind === "native-task-group" && item.tasks.some(task => task.toolCallId === data.toolCallId)));
156
+ if (!nativeCall) appendChatCall(chat, event);
157
+ }
158
+
93
159
  function clampHistoryItems(items, maxItems) {
94
160
  const list = Array.isArray(items) ? items.filter(Boolean) : [];
95
161
  const safeMax = Math.max(DEFAULT_HISTORY_EVENT_LIMIT, Number(maxItems) || DEFAULT_HISTORY_EVENT_LIMIT);
@@ -881,7 +947,21 @@ function formatActivity(event) {
881
947
  const body = formatEventSnippet(event);
882
948
  let runs = null;
883
949
 
950
+ // Native child transcripts remain inspectable as events, but only their
951
+ // lifecycle belongs in the parent's activity feed.
952
+ if (event.eventType?.startsWith("native.") || event.eventType === "session.background_tasks_changed") return null;
884
953
  switch (event.eventType) {
954
+ case "subagent.started":
955
+ case "subagent.completed":
956
+ case "subagent.failed": {
957
+ const phase = event.eventType.slice("subagent.".length);
958
+ const name = event.data?.agentDisplayName || event.data?.agentName || "agent";
959
+ const duration = Number.isFinite(event.data?.durationMs)
960
+ ? ` (${formatHumanDurationSeconds(event.data.durationMs / 1000)})` : "";
961
+ runs = buildLabeledActivityRuns(time, "[native agent]", "cyan", `${name} ${phase}${duration}`, phase === "failed" ? "red" : "white");
962
+ break;
963
+ }
964
+ case "subagent.configured":
885
965
  case "assistant.usage":
886
966
  case "session.info":
887
967
  case "session.idle":
@@ -1226,11 +1306,9 @@ export function buildHistoryModel(events = [], options = {}) {
1226
1306
 
1227
1307
  for (const event of events) {
1228
1308
  storedEvents.push(event);
1229
- appendChatCall(chat, event);
1230
- if (event.eventType === "session.error") {
1231
- const warning = buildSessionWarning(event);
1232
- if (warning) chat.push(warning);
1233
- }
1309
+ appendNativeTaskEvent(chat, event);
1310
+ appendParentChatCall(chat, event);
1311
+ appendSessionWarning(chat, event);
1234
1312
  if (["user.message", "session.turn_completed", "session.turn_stopped", "session.epoch_committed"].includes(event.eventType)) {
1235
1313
  settleAssistantResponses(chat, event);
1236
1314
  }
@@ -1309,6 +1387,7 @@ export function appendEventToHistory(history, event) {
1309
1387
  ? existingEvents
1310
1388
  : [...existingEvents, event].slice(-loadedEventLimit);
1311
1389
  const next = {
1390
+ nativeTaskSnapshot: history?.nativeTaskSnapshot,
1312
1391
  closedLiveKeys: history?.closedLiveKeys || [],
1313
1392
  closedLiveStreams: history?.closedLiveStreams || [],
1314
1393
  chat: clampHistoryItems(history?.chat || [], loadedEventLimit),
@@ -1328,14 +1407,10 @@ export function appendEventToHistory(history, event) {
1328
1407
  stoppedMessageIds: Array.isArray(history?.stoppedMessageIds) ? history.stoppedMessageIds : [],
1329
1408
  };
1330
1409
 
1331
- if (event.eventType === "session.error") {
1332
- const warning = buildSessionWarning(event);
1333
- if (warning && !next.chat.some(item => item.id === warning.id)) {
1334
- next.chat = clampHistoryItems([...next.chat, warning], loadedEventLimit);
1335
- }
1336
- }
1337
-
1338
- appendChatCall(next.chat, event);
1410
+ appendNativeTaskEvent(next.chat, event);
1411
+ appendParentChatCall(next.chat, event);
1412
+ appendSessionWarning(next.chat, event);
1413
+ next.chat = clampHistoryItems(next.chat, loadedEventLimit);
1339
1414
 
1340
1415
  if (["user.message", "session.turn_completed", "session.turn_stopped", "session.epoch_committed"].includes(event.eventType)) {
1341
1416
  settleAssistantResponses(next.chat, event);
@@ -57,6 +57,15 @@ export const activeMoaDashboard = value => value.dashboards.find(d => d.id === v
57
57
  export function updateMoaDashboard(value, dashboardId, patch) {
58
58
  return normalizeMoa({ ...value, dashboards: value.dashboards.map(d => d.id === dashboardId ? { ...d, ...patch } : d) });
59
59
  }
60
+ export function moveMoaDashboard(value, dashboardId, toIndex) {
61
+ const normalized = normalizeMoa(value);
62
+ const fromIndex = normalized.dashboards.findIndex(d => d.id === dashboardId);
63
+ if (fromIndex < 0 || !Number.isInteger(toIndex)) return normalized;
64
+ const dashboards = [...normalized.dashboards];
65
+ const [dashboard] = dashboards.splice(fromIndex, 1);
66
+ dashboards.splice(Math.max(0, Math.min(dashboards.length, toIndex)), 0, dashboard);
67
+ return { ...normalized, dashboards };
68
+ }
60
69
  export function replaceMoaNode(tree, nodeId, next) {
61
70
  if (!tree) return null;
62
71
  if (tree.id === nodeId) return next;
@@ -0,0 +1,197 @@
1
+ // Native Copilot work is local to one parent turn. Durable spawn_agent
2
+ // sessions deliberately do not enter this projection.
3
+ const TERMINAL = new Set(["completed", "failed", "cancelled", "interrupted"]);
4
+ const clip = (value, max = 240) => typeof value === "string" ? value.trim().slice(0, max) : "";
5
+ const timeMs = value => {
6
+ const ms = typeof value === "number" ? value : new Date(value).getTime();
7
+ return Number.isFinite(ms) && ms > 0 ? ms : null;
8
+ };
9
+ const resultText = value => typeof value === "string" ? value
10
+ : value?.detailedContent || value?.content || value?.textResultForLlm || value?.message || value?.error?.message || "";
11
+
12
+ export const NATIVE_TASK_LABELS = {
13
+ starting: "Starting", running: "Running", waiting: "Waiting", completed: "Done",
14
+ failed: "Failed", cancelled: "Cancelled", interrupted: "Interrupted",
15
+ };
16
+
17
+ function findTask(chat, task) {
18
+ for (let groupIndex = chat.length - 1; groupIndex >= 0; groupIndex--) {
19
+ const group = chat[groupIndex];
20
+ if (group.kind !== "native-task-group") continue;
21
+ const taskIndex = group.tasks.findLastIndex(item =>
22
+ (task.toolCallId && item.toolCallId === task.toolCallId)
23
+ || (task.agentId && item.agentId === task.agentId && (!task.toolCallId || !item.toolCallId))
24
+ || (task.id && item.id === task.id));
25
+ if (taskIndex >= 0) return { groupIndex, taskIndex };
26
+ }
27
+ return null;
28
+ }
29
+
30
+ function updateTask(chat, patch, event, { authoritative = false } = {}) {
31
+ const match = findTask(chat, patch);
32
+ const previous = match ? chat[match.groupIndex].tasks[match.taskIndex] : null;
33
+ if (previous?.ownerId && patch.ownerId && previous.ownerId !== patch.ownerId) return;
34
+ if (patch.revision != null && previous?.revision != null && patch.revision < previous.revision) return;
35
+ const next = {
36
+ id: patch.toolCallId || patch.agentId || patch.id,
37
+ title: "Native task", status: "starting", toolCalls: 0,
38
+ startedAt: timeMs(patch.startedAt) || timeMs(event.createdAt),
39
+ ...previous,
40
+ ...Object.fromEntries(Object.entries(patch).filter(([,value]) => value !== undefined)),
41
+ telemetryStale: false,
42
+ };
43
+ if (!next.id) return;
44
+ next.id = previous?.id || next.id;
45
+ next.startedAt = previous?.startedAt || timeMs(patch.startedAt) || timeMs(event.createdAt);
46
+ next.completedAt = timeMs(next.completedAt);
47
+ // Cleanup and late progress must not replace a confirmed outcome. A
48
+ // cancelled:true completion is handled before it becomes terminal.
49
+ if (previous && TERMINAL.has(previous.status)
50
+ && (!previous.inferredTerminal || !TERMINAL.has(patch.status))) {
51
+ next.status = previous.status;
52
+ next.error = previous.error;
53
+ next.completedAt = previous.completedAt;
54
+ next.durationMs = previous.durationMs;
55
+ if (!TERMINAL.has(patch.status) || patch.status === "cancelled") next.toolCalls = previous.toolCalls;
56
+ if (!patch.result && !patch.error) next.preview = previous.preview;
57
+ } else if (TERMINAL.has(patch.status) && !patch.inferredTerminal) {
58
+ next.inferredTerminal = false;
59
+ // A late real outcome replaces the explanatory text of an inferred
60
+ // interruption, rather than displaying that stale error under Done.
61
+ if (previous?.inferredTerminal) next.error = patch.error || "";
62
+ }
63
+ next.title = clip(next.title, 160) || "Native task";
64
+ next.preview = clip(next.preview);
65
+ next.result = clip(next.result, 4000);
66
+ next.error = clip(next.error, 1000);
67
+ next.toolCalls = Math.max(Number(previous?.toolCalls) || 0, Number(next.toolCalls) || 0);
68
+ next.recentActivity = Array.isArray(next.recentActivity)
69
+ ? next.recentActivity.slice(-5).map(a => ({ message: clip(a.message), timestamp: a.timestamp })) : [];
70
+ if (match) {
71
+ const group = chat[match.groupIndex];
72
+ const tasks = [...group.tasks];
73
+ tasks[match.taskIndex] = next;
74
+ chat[match.groupIndex] = { ...group, tasks };
75
+ return;
76
+ }
77
+ // A retained snapshot can arrive after newer chat. Insert at its original
78
+ // start time instead of attaching it permanently to the transcript tail.
79
+ const later = next.startedAt ? chat.findIndex(item => Number(item.createdAt) > next.startedAt) : -1;
80
+ const insertion = later < 0 ? chat.length : later;
81
+ const prior = chat[insertion - 1];
82
+ if (prior?.kind === "native-task-group" && prior.tasks.length < 32) {
83
+ chat[insertion - 1] = { ...prior, tasks: [...prior.tasks, next] };
84
+ } else {
85
+ chat.splice(insertion, 0, {
86
+ id: `native-tasks:${event.sessionId}:${next.id}`, kind: "native-task-group", role: "system",
87
+ createdAt: next.startedAt, tasks: [next],
88
+ });
89
+ }
90
+ }
91
+
92
+ export function appendNativeTaskEvent(chat, event) {
93
+ const type = event?.eventType || "";
94
+ const data = event?.data || {};
95
+ if (type === "native.task_updated") {
96
+ if (data.id && NATIVE_TASK_LABELS[data.status]) updateTask(chat, data, event, { authoritative: true });
97
+ return;
98
+ }
99
+ if (type === "session.turn_completed" || type === "session.turn_stopped") {
100
+ for (let i = 0; i < chat.length; i++) {
101
+ if (chat[i].kind !== "native-task-group") continue;
102
+ chat[i] = { ...chat[i], tasks: chat[i].tasks.map(task => TERMINAL.has(task.status) ? task : {
103
+ ...task, status: "interrupted", inferredTerminal: true, completedAt: timeMs(event.createdAt),
104
+ error: type === "session.turn_stopped" ? "The parent turn was stopped." : "The parent turn ended before a task result was confirmed.",
105
+ }) };
106
+ }
107
+ return;
108
+ }
109
+ const lifecycle = type.startsWith("subagent.");
110
+ const child = type.startsWith("native.");
111
+ const taskTool = !child && !data.nativeAgentId && (data.toolName || data.name) === "task"
112
+ && ["tool.execution_start", "tool.execution_complete"].includes(type);
113
+ if (!lifecycle && !child && !taskTool) return;
114
+ const args = data.arguments || data.args || {};
115
+ const toolCallId = child ? data.parentToolCallId : data.toolCallId;
116
+ const agentId = data.nativeAgentId || data.agentId;
117
+ const patch = { toolCallId, agentId };
118
+ const found = findTask(chat, patch);
119
+ const previous = found ? chat[found.groupIndex].tasks[found.taskIndex] : null;
120
+ if (!previous && !lifecycle && !taskTool) return;
121
+ if (!previous || taskTool || type === "subagent.started") Object.assign(patch, {
122
+ title: args.description || data.agentDisplayName || args.name || data.agentName || "Native task",
123
+ profile: args.agent_type || data.agentName || data.agentType,
124
+ model: data.model,
125
+ });
126
+ if (type === "subagent.configured") {
127
+ Object.assign(patch, { model: data.model, reasoningEffort: data.reasoningEffort });
128
+ } else if (type === "subagent.started") {
129
+ patch.status = "running";
130
+ } else if (type === "subagent.completed" || type === "subagent.failed") {
131
+ Object.assign(patch, {
132
+ status: data.cancelled ? "cancelled" : type === "subagent.failed" ? "failed" : "completed",
133
+ completedAt: event.createdAt, durationMs: data.durationMs, toolCalls: data.totalToolCalls,
134
+ ...(data.cancelled ? { error: "Task was cancelled before returning a result." }
135
+ : type === "subagent.failed" ? { error: clip(resultText(data.error) || data.message || "Native task failed.", 1000) } : {}),
136
+ });
137
+ } else if (type === "tool.execution_start") {
138
+ patch.status = "starting";
139
+ } else if (type === "tool.execution_complete") {
140
+ const failed = data.success === false || data.result?.success === false || Boolean(data.error)
141
+ || ["failure", "failed", "rejected", "denied"].includes(data.result?.resultType ?? data.resultType);
142
+ const text = resultText(data.result) || resultText(data.error);
143
+ Object.assign(patch, { status: failed ? "failed" : "completed", completedAt: event.createdAt,
144
+ ...(failed ? { error: clip(text, 1000) || "Native task request failed." } : { result: clip(text, 4000) }) });
145
+ } else if (type === "native.tool.execution_start") {
146
+ const ids = previous.toolCallIds || [];
147
+ patch.toolCallIds = data.toolCallId && !ids.includes(data.toolCallId) ? [...ids, data.toolCallId].slice(-500) : ids;
148
+ patch.toolCalls = (previous.toolCalls || 0) + (patch.toolCallIds !== ids ? 1 : 0);
149
+ patch.preview = args.description || (data.toolName ? `Using ${data.toolName}` : "Working");
150
+ } else if (type === "native.assistant.message") {
151
+ // Do not expose reasoning, tool arguments, or the whole child transcript.
152
+ patch.preview = clip(data.content);
153
+ } else return;
154
+ updateTask(chat, patch, event);
155
+ }
156
+
157
+ export function applyNativeTaskSnapshot(history, payload, meta = {}) {
158
+ if (payload?.phase === "unavailable" && history) return { ...history,
159
+ nativeTaskSnapshot: history.nativeTaskSnapshot ? { ...history.nativeTaskSnapshot, liveSeq: undefined } : undefined,
160
+ chat: history.chat.map(group => group.kind !== "native-task-group" ? group : { ...group,
161
+ tasks: group.tasks.map(task => TERMINAL.has(task.status) ? task : { ...task, telemetryStale: true }) }) };
162
+ if (payload?.version !== 1 || !Array.isArray(payload.tasks) || !payload.ownerId) return history;
163
+ const current = history || { chat: [], activity: [], events: [], lastSeq: 0 };
164
+ const latest = current.nativeTaskSnapshot;
165
+ const newestRecordedOwner = Math.max(0, ...current.chat
166
+ .filter(group => group.kind === "native-task-group")
167
+ .flatMap(group => group.tasks.map(task => Number(task.ownerStartedAt) || 0)));
168
+ // The retained live row can belong to an old publisher that finished
169
+ // writing after a newer turn. On reconnect, durable summary owners fence
170
+ // that row even when this viewer has not received a live snapshot yet.
171
+ if (Number.isFinite(payload.ownerStartedAt) && payload.ownerStartedAt < newestRecordedOwner) {
172
+ if (latest?.ownerStartedAt >= newestRecordedOwner) return current;
173
+ return { ...current, chat: current.chat.map(group => group.kind !== "native-task-group" ? group : { ...group,
174
+ tasks: group.tasks.map(task => !TERMINAL.has(task.status) && task.ownerStartedAt === newestRecordedOwner
175
+ ? { ...task, telemetryStale: true } : task) }) };
176
+ }
177
+ if (Number.isFinite(latest?.ownerStartedAt) && Number.isFinite(payload.ownerStartedAt)
178
+ && payload.ownerStartedAt < latest.ownerStartedAt) return current;
179
+ if (latest?.ownerId === payload.ownerId && latest.revision >= payload.revision) return current;
180
+ if (latest?.ownerId === payload.ownerId && Number.isFinite(meta.seq)
181
+ && Number.isFinite(latest?.liveSeq) && meta.seq <= latest.liveSeq) return current;
182
+ const chat = [...current.chat];
183
+ for (const task of payload.tasks.slice(0, 32)) {
184
+ if (!task?.id || !NATIVE_TASK_LABELS[task.status]) continue;
185
+ const ended = current.events?.some(e => ["session.turn_completed", "session.turn_stopped"].includes(e.eventType)
186
+ && timeMs(e.createdAt) > timeMs(task.startedAt));
187
+ const patch = { ...task, ownerId: payload.ownerId, ownerStartedAt: payload.ownerStartedAt, revision: payload.revision };
188
+ if (ended && !TERMINAL.has(patch.status)) {
189
+ patch.status = "interrupted";
190
+ patch.inferredTerminal = true;
191
+ patch.error = "The parent turn ended before a task result was confirmed.";
192
+ }
193
+ updateTask(chat, patch, { sessionId: meta.sessionId, createdAt: task.startedAt }, { authoritative: true });
194
+ }
195
+ return { ...current, chat, nativeTaskSnapshot: { ownerId: payload.ownerId, ownerStartedAt: payload.ownerStartedAt,
196
+ revision: payload.revision, liveSeq: meta.seq } };
197
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Some model-authored questions contain literal newline escapes. Decode those
3
+ * for presentation before markdown parsing, including mixed real/escaped lines.
4
+ * Keep stored question/choice identity intact for answers and history matching.
5
+ * Code spans/fences, Windows paths, quoted escape literals and doubled slashes
6
+ * are literal content, not formatting. Do not run a general JSON unescape here.
7
+ */
8
+ export function normalizeQuestionForDisplay(value) {
9
+ const source = String(value ?? "");
10
+ if (!source.includes("\\n")) return source;
11
+ const tokens = /(`+)[\s\S]*?\1(?!`)|(~{3,})[\s\S]*?\2|(["'])[A-Za-z]:\\[^\r\n]*?\3|(?<!\w)(?:[A-Za-z]:\\|\\\\)(?:(?!\\n\\n|\\r\\n\\r\\n)[^\s"'`])+|(["'])(?:\\r\\n|\\[nr])\4|\\\\|\\r\\n|\\n/g;
12
+ return source.replace(tokens, token => token === "\\n" || token === "\\r\\n" ? "\n" : token);
13
+ }
@@ -41,6 +41,15 @@ function packageResponseIsStale(packages, action) {
41
41
  return false;
42
42
  }
43
43
 
44
+ function clearFeatureIdentity(features) {
45
+ return { mode: "mine", userId: null, data: null, users: [], drafts: {}, loading: false, saving: false,
46
+ error: null, generation: (features?.generation || 0) + 1, fetchedAt: null, userQuery: "" };
47
+ }
48
+
49
+ function featurePrincipalKey(principal) {
50
+ return JSON.stringify([principal?.provider, principal?.subject]);
51
+ }
52
+
44
53
  /** A provider name, or null. Names are cluster-unique and never blank. */
45
54
  function normalizeProviderName(value) {
46
55
  const name = String(value ?? "").trim();
@@ -646,6 +655,7 @@ function updateUiForSessionSelection(state, nextActiveSessionId) {
646
655
  },
647
656
  followBottom: {
648
657
  ...(state.ui.followBottom || {}),
658
+ chat: true,
649
659
  inspector: true,
650
660
  activity: true,
651
661
  },
@@ -964,6 +974,9 @@ function baseReducer(state, action) {
964
974
  case "auth/context":
965
975
  return {
966
976
  ...state,
977
+ admin: featurePrincipalKey(state.auth?.principal) !== featurePrincipalKey(action.principal)
978
+ || state.auth?.authorization?.role !== action.authorization?.role
979
+ ? { ...state.admin, features: clearFeatureIdentity(state.admin.features) } : state.admin,
967
980
  auth: {
968
981
  principal: action.principal ?? null,
969
982
  authorization: action.authorization ?? null,
@@ -1481,7 +1494,7 @@ function baseReducer(state, action) {
1481
1494
  };
1482
1495
 
1483
1496
  case "ui/followBottom": {
1484
- if (action.pane !== "inspector" && action.pane !== "activity") {
1497
+ if (action.pane !== "chat" && action.pane !== "inspector" && action.pane !== "activity") {
1485
1498
  return state;
1486
1499
  }
1487
1500
  const nextFollowBottom = Boolean(action.followBottom);
@@ -1876,14 +1889,15 @@ function baseReducer(state, action) {
1876
1889
 
1877
1890
  case "sessions/selected": {
1878
1891
  state = { ...state, sessions: { ...state.sessions, listDeselected: false } };
1879
- // Per-session chat scroll memory: stash the outgoing session's
1880
- // offset and restore the incoming one's. If new chat arrives on
1881
- // re-entry, history/set's activeChatUpdated reset still snaps to
1882
- // latest — "latest chat, or where you left it".
1892
+ // Per-session chat scroll memory: stash both the outgoing offset
1893
+ // and whether it follows the bottom. A paused reading position
1894
+ // stays paused as new messages arrive and across session switches.
1883
1895
  const previousActiveId = state.sessions.activeSessionId;
1884
1896
  const savedChatScroll = { ...(state.ui.chatScrollBySession || {}) };
1897
+ const savedChatFollowBottom = { ...(state.ui.chatFollowBottomBySession || {}) };
1885
1898
  if (previousActiveId && previousActiveId !== action.sessionId) {
1886
1899
  savedChatScroll[previousActiveId] = Number(state.ui.scroll?.chat) || 0;
1900
+ savedChatFollowBottom[previousActiveId] = state.ui.followBottom?.chat !== false;
1887
1901
  }
1888
1902
  // Per-session prompt drafts: a half-written message belongs to the
1889
1903
  // session it was written in. Stash the outgoing draft (text +
@@ -1936,6 +1950,7 @@ function baseReducer(state, action) {
1936
1950
  ui: {
1937
1951
  ...state.ui,
1938
1952
  chatScrollBySession: savedChatScroll,
1953
+ chatFollowBottomBySession: savedChatFollowBottom,
1939
1954
  promptDraftBySession: savedDrafts,
1940
1955
  prompt: nextPrompt,
1941
1956
  promptCursor: nextPromptCursor,
@@ -1954,6 +1969,7 @@ function baseReducer(state, action) {
1954
1969
  },
1955
1970
  followBottom: {
1956
1971
  ...(state.ui.followBottom || {}),
1972
+ chat: savedChatFollowBottom[action.sessionId] !== false,
1957
1973
  inspector: true,
1958
1974
  activity: true,
1959
1975
  },
@@ -2201,7 +2217,7 @@ function baseReducer(state, action) {
2201
2217
  ...state.history,
2202
2218
  bySessionId: nextHistory,
2203
2219
  },
2204
- ui: activeChatUpdated
2220
+ ui: activeChatUpdated && state.ui.followBottom?.chat !== false
2205
2221
  ? {
2206
2222
  ...state.ui,
2207
2223
  scroll: {
@@ -2222,6 +2238,8 @@ function baseReducer(state, action) {
2222
2238
  for (const id of ids) delete nextOutbox[id];
2223
2239
  const nextChatScroll = { ...(state.ui.chatScrollBySession || {}) };
2224
2240
  for (const id of ids) delete nextChatScroll[id];
2241
+ const nextChatFollowBottom = { ...(state.ui.chatFollowBottomBySession || {}) };
2242
+ for (const id of ids) delete nextChatFollowBottom[id];
2225
2243
  const nextDrafts = { ...(state.ui.promptDraftBySession || {}) };
2226
2244
  for (const id of ids) delete nextDrafts[id];
2227
2245
  return {
@@ -2237,6 +2255,7 @@ function baseReducer(state, action) {
2237
2255
  ui: {
2238
2256
  ...state.ui,
2239
2257
  chatScrollBySession: nextChatScroll,
2258
+ chatFollowBottomBySession: nextChatFollowBottom,
2240
2259
  promptDraftBySession: nextDrafts,
2241
2260
  },
2242
2261
  };
@@ -2506,6 +2525,9 @@ function baseReducer(state, action) {
2506
2525
  loading: false,
2507
2526
  loadError: null,
2508
2527
  profile: action.profile || null,
2528
+ features: featurePrincipalKey(state.admin.profile) !== featurePrincipalKey(action.profile)
2529
+ || Boolean(state.admin.profile?.isAdmin) !== Boolean(action.profile?.isAdmin)
2530
+ ? clearFeatureIdentity(state.admin.features) : state.admin.features,
2509
2531
  ghcpKey: {
2510
2532
  ...state.admin.ghcpKey,
2511
2533
  // A successful load doesn't clobber an in-progress
@@ -2957,9 +2979,11 @@ function baseReducer(state, action) {
2957
2979
  };
2958
2980
  }
2959
2981
  case "admin/section": {
2960
- const section = ["packages", "workers", "ghcp"].includes(action.section) ? action.section : "ghcp";
2982
+ const section = ["providers", "packages", "workers", "features", "ghcp"].includes(action.section) ? action.section : "ghcp";
2961
2983
  return { ...state, admin: { ...state.admin, section } };
2962
2984
  }
2985
+ case "admin/features":
2986
+ return { ...state, admin: { ...state.admin, features: { ...state.admin.features, ...action.patch } } };
2963
2987
  case "admin/workers/attempt": {
2964
2988
  const workers = state.admin.workers || {};
2965
2989
  return { ...state, admin: { ...state.admin, workers: { ...workers, attempts: (workers.attempts || 0) + 1, lastAttemptAt: Date.now(), lastSkip: action.skip || null } } };
@@ -29,6 +29,8 @@ import {
29
29
  getContextHeaderBadge,
30
30
  } from "./context-usage.js";
31
31
  import { canonicalSystemTitle } from "./system-titles.js";
32
+ import { matchesSessionError } from "./session-warning.js";
33
+ import { normalizeQuestionForDisplay } from "./question-display.js";
32
34
  import { withSessionWarnings, isActivityOnlySessionError } from "./session-errors.js";
33
35
  import {
34
36
  BUDGET_PERIODS,
@@ -1543,11 +1545,11 @@ export function canStopSessionTurn(session) {
1543
1545
  // raised them. They used to stamp session.updatedAt, which moves on EVERY
1544
1546
  // poll and status tick — so the card's clock jumped forward with each update
1545
1547
  // and read as flicker.
1546
- function latestEventCreatedAtMs(events = [], eventTypes = []) {
1548
+ function latestEventCreatedAtMs(events = [], eventTypes = [], predicate = () => true) {
1547
1549
  const wanted = new Set(eventTypes);
1548
1550
  for (let index = (events || []).length - 1; index >= 0; index -= 1) {
1549
1551
  const event = events[index];
1550
- if (!event || !wanted.has(event.eventType)) continue;
1552
+ if (!event || !wanted.has(event.eventType) || !predicate(event)) continue;
1551
1553
  const createdAt = event.createdAt;
1552
1554
  const ms = createdAt instanceof Date
1553
1555
  ? createdAt.getTime()
@@ -1561,7 +1563,7 @@ function buildPendingQuestionMessage(session, events = []) {
1561
1563
  const pendingQuestion = session?.pendingQuestion;
1562
1564
  if (!pendingQuestion?.question) return null;
1563
1565
 
1564
- const body = [String(pendingQuestion.question).trim()];
1566
+ const body = [normalizeQuestionForDisplay(pendingQuestion.question).trim()];
1565
1567
  const choices = Array.isArray(pendingQuestion.choices)
1566
1568
  ? pendingQuestion.choices.filter((choice) => typeof choice === "string" && choice.trim())
1567
1569
  : [];
@@ -1569,7 +1571,7 @@ function buildPendingQuestionMessage(session, events = []) {
1569
1571
  if (choices.length > 0) {
1570
1572
  body.push("", "Choices:");
1571
1573
  for (const choice of choices) {
1572
- body.push(`- ${choice}`);
1574
+ body.push(`- ${normalizeQuestionForDisplay(choice)}`);
1573
1575
  }
1574
1576
  }
1575
1577
 
@@ -1661,10 +1663,11 @@ function buildSessionErrorMessage(session, events = []) {
1661
1663
  role: "system",
1662
1664
  text: body,
1663
1665
  time: "",
1664
- // The error event when one was recorded, else the end of the turn
1665
- // that produced the error; never the session's rolling updatedAt.
1666
- createdAt: latestEventCreatedAtMs(events, ["session.error"])
1667
- ?? latestEventCreatedAtMs(events, ["session.turn_completed", "assistant.turn_end"]),
1666
+ // Only an actual failure can anchor this card. A later successful
1667
+ // turn must never move an earlier warning's timestamp forward.
1668
+ createdAt: latestEventCreatedAtMs(events, ["session.error", "session.turn_completed"],
1669
+ (event) => matchesSessionError(event.eventType === "session.error" ? event.data?.message
1670
+ : event.data?.resultType === "error" ? event.data?.errorMessage : null, errorText)),
1668
1671
  cardTitle: isFailed ? "Error" : "Warning",
1669
1672
  cardTitleColor: isFailed ? "red" : "yellow",
1670
1673
  cardBorderColor: isFailed ? "red" : "yellow",
@@ -1912,8 +1915,24 @@ export function selectActiveChat(state) {
1912
1915
  messages.push(answeredQuestionMessage);
1913
1916
  }
1914
1917
  if (sessionErrorMessage) {
1915
- const index = messages.findIndex(message => sessionErrorMessage.createdAt != null && message.createdAt > sessionErrorMessage.createdAt);
1916
- messages.splice(index < 0 ? messages.length : index, 0, sessionErrorMessage);
1918
+ // History owns the warning's position and identity. Status may add
1919
+ // current retry/failure details, but must not append the same warning
1920
+ // after the newer conversation on every poll.
1921
+ const warningIndex = messages.findLastIndex((message) => message.kind === "session-warning"
1922
+ && matchesSessionError(message.text, session.error));
1923
+ if (warningIndex < 0) {
1924
+ const index = messages.findIndex(message => sessionErrorMessage.createdAt != null && message.createdAt > sessionErrorMessage.createdAt);
1925
+ messages.splice(index < 0 ? messages.length : index, 0, sessionErrorMessage);
1926
+ } else if (warningIndex === messages.length - 1 || sessionErrorMessage.cardTitle === "Error") {
1927
+ const warning = messages[warningIndex];
1928
+ messages[warningIndex] = {
1929
+ ...warning,
1930
+ ...sessionErrorMessage,
1931
+ id: warning.id,
1932
+ createdAt: warning.createdAt,
1933
+ time: warning.time,
1934
+ };
1935
+ }
1917
1936
  }
1918
1937
  return messages;
1919
1938
  }
@@ -2597,7 +2616,7 @@ function buildChatMessageLinesUncached(message, maxWidth, options = {}) {
2597
2616
  ...(askedAndAnswered.question === "a question" ? [] : buildMessageCardLines({
2598
2617
  title: "Question",
2599
2618
  timestamp: formatTimestamp(message?.createdAt || message?.time),
2600
- body: askedAndAnswered.question,
2619
+ body: normalizeQuestionForDisplay(askedAndAnswered.question),
2601
2620
  width: Math.max(20, maxWidth),
2602
2621
  titleColor: USER_CHAT_COLOR,
2603
2622
  borderColor: USER_CHAT_COLOR,
@@ -3069,7 +3088,20 @@ export function selectChatLines(state, maxWidth = 80, options = {}) {
3069
3088
  };
3070
3089
  const lines = [];
3071
3090
  for (const [index, message] of messages.entries()) {
3072
- if (message?.kind === "epoch-divider") {
3091
+ if (message?.kind === "native-task-group") {
3092
+ if (options.tableMode === "sentinel") {
3093
+ lines.push({ kind: "nativeTasks", group: message });
3094
+ } else {
3095
+ const labels = { starting: "Starting", running: "Running", waiting: "Waiting", completed: "Done",
3096
+ failed: "Failed", cancelled: "Cancelled", interrupted: "Interrupted" };
3097
+ appendChatBlockLines(lines, buildChatMessageLines({ ...message, cardTitle: "Native tasks",
3098
+ cardTitleColor: "cyan", cardBorderColor: "cyan",
3099
+ text: message.tasks.map(task => `[${labels[task.status] || task.status}] ${task.title}`
3100
+ + (task.toolCalls ? ` · ${task.toolCalls} calls` : "")
3101
+ + (task.error || task.result || task.preview ? `\n ${String(task.error || task.result || task.preview).replace(/\s+/g, " ").slice(0, 240)}` : "")).join("\n\n"),
3102
+ }, maxWidth, buildOptions));
3103
+ }
3104
+ } else if (message?.kind === "epoch-divider") {
3073
3105
  lines.push(buildEpochDividerLine(message, maxWidth));
3074
3106
  } else if (message?.kind === "regen-refused") {
3075
3107
  lines.push(buildRegenRefusedLine(message, maxWidth));
@@ -4422,11 +4454,12 @@ export function selectAdminConsole(state) {
4422
4454
  // Settings tree — the session-list-slot navigation. Rendered by both
4423
4455
  // hosts; `kind` drives affordances (section rows switch panes, package
4424
4456
  // rows select a package).
4425
- const section = ["providers", "packages", "workers"].includes(admin.section) ? admin.section : "providers";
4457
+ const section = ["providers", "packages", "workers", "features"].includes(admin.section) ? admin.section : "providers";
4426
4458
  const settingsTree = [
4427
4459
  { id: "providers", kind: "section", depth: 0, label: "Model Providers", selected: false },
4428
4460
  { id: "myProviders", kind: "subsection", depth: 1, label: "My Providers", selected: section === "providers" && providerPage === "mine" },
4429
4461
  ...(isAdmin ? [{ id: "sharedProviders", kind: "subsection", depth: 1, label: "Shared Providers", selected: section === "providers" && providerPage === "shared" }] : []),
4462
+ { id: "features", kind: "section", depth: 0, label: "Feature flags", selected: section === "features" },
4430
4463
  { id: "agents", kind: "section", depth: 0, label: "Agents", selected: section === "packages" && !pkgState.selectedName },
4431
4464
  { id: "group:shared", kind: "group", depth: 1, label: "Shared", count: sharedRows.length },
4432
4465
  ...sharedRows.map((row) => ({ id: `pkg:shared:${row.name}`, kind: "package", depth: 2, label: row.name, ...row })),