@tea-agent/loop-agent 0.40.0-next.4 → 0.40.0-next.6

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 (134) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/build-stamp.json +3 -3
  3. package/dist/executors/shell-executor.js +4 -1
  4. package/dist/infrastructure/harness/atomic-write.js +23 -0
  5. package/dist/worker/console/chat/chat-event-store.js +62 -4
  6. package/dist/worker/console/chat/context-panel.js +7 -0
  7. package/dist/worker/console/chat/deferred-turn.js +12 -0
  8. package/dist/worker/console/chat/pi-runtime.js +264 -16
  9. package/dist/worker/console/chat/provider-error.js +98 -0
  10. package/dist/worker/console/chat/routes.js +292 -29
  11. package/dist/worker/console/chat/session-stats.js +179 -0
  12. package/dist/worker/console/chat/session-store.js +93 -63
  13. package/dist/worker/console/chat/todos.js +115 -0
  14. package/dist/worker/console/chat/tool-preview.js +42 -0
  15. package/dist/worker/console/chat/turn-process.js +20 -55
  16. package/dist/worker/console/chat/user-questions.js +266 -0
  17. package/dist/worker/console/chat/workspace-landing.js +14 -0
  18. package/dist/worker/console/native-directory-picker.js +13 -0
  19. package/dist/worker/console/routes.js +309 -1
  20. package/dist/worker/console/security.js +4 -4
  21. package/dist/worker/console/server.js +529 -186
  22. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-CTx1TrNa.js → abnfDiagram-N423BO3Z-DGt5TRsk.js} +1 -1
  23. package/dist/worker/console/static/assets/{arc-B4i9cVxX.js → arc-DKBSJsXm.js} +1 -1
  24. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-BAXE8_Rl.js → architectureDiagram-T3A2C74G-BnhooRBs.js} +1 -1
  25. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-C5Xh59at.js → blockDiagram-VBNYF7ZC-Bm9qkrJP.js} +1 -1
  26. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-B_asOzAu.js → c4Diagram-5PPSVZJV-B2cFh4bO.js} +1 -1
  27. package/dist/worker/console/static/assets/channel-DSgFX07x.js +1 -0
  28. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-wRO_iRNc.js → chunk-2GRJ4B5K-DhvWRI03.js} +1 -1
  29. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-ByOaSh_G.js → chunk-2Q5K7J3B-chzTP_2w.js} +1 -1
  30. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DjLzudan.js → chunk-5RXB4S5H-SdWIR12m.js} +1 -1
  31. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-JCNGkXYT.js → chunk-5VM5RSS4-C2-8Eo_A.js} +1 -1
  32. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-BhrPXXMZ.js → chunk-6Q2QTUOP-DvbE1yQW.js} +1 -1
  33. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-TJ5JuEBT.js → chunk-GF5L2VYU-CEenyPTz.js} +1 -1
  34. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-VVthn_mD.js → chunk-JWPE2WC7-CcaZrq0k.js} +1 -1
  35. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-D60-Mny4.js → chunk-KBJHAD2P-FGngOTto.js} +1 -1
  36. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-zXv7aVye.js → chunk-RYQCIY6F-jT_yX3sf.js} +1 -1
  37. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-By9VQoi1.js → chunk-XXDRQBXY-CjE9LQOM.js} +1 -1
  38. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-unJObRls.js +1 -0
  39. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-unJObRls.js +1 -0
  40. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-C4zQLe-M.js → cose-bilkent-JH36ORCC-GQWns1tb.js} +1 -1
  41. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-wh5N2ReN.js → cynefin-VYW2F7L2-CGRpxWNJ.js} +1 -1
  42. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Dlu5wIqs.js → cynefinDiagram-MW4NZA55-Cd3uFGE4.js} +1 -1
  43. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CrDgthc6.js → dagre-VZM6K2ZE-DmDHuUW0.js} +1 -1
  44. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-zN1clg27.js → diagram-7IWD3JNH-kAr--hF3.js} +1 -1
  45. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BN-o7J7-.js → diagram-B4RE2ZJO-woahL2Oh.js} +1 -1
  46. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-C8SuPt0d.js → diagram-LBJQPF4R-7syNVaNf.js} +1 -1
  47. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BoizuHbZ.js → diagram-Q27KOJAE-BELPXiaY.js} +1 -1
  48. package/dist/worker/console/static/assets/{diagram-UB23O5K3-BzUzXixG.js → diagram-UB23O5K3-CrPa4aXi.js} +1 -1
  49. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Ev3lA4Cf.js → ebnfDiagram-BXEA7PRR-B48u2VVe.js} +1 -1
  50. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-GALUzRbs.js → erDiagram-JOGREHBK-DhBTfwTF.js} +1 -1
  51. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CZJN65Sb.js → flowDiagram-UKHOOZJN-CAeZ5nDU.js} +1 -1
  52. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-JLB9OESl.js → ganttDiagram-PKOTCBZU-CkIFeFLN.js} +1 -1
  53. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-CcvCr4GD.js → gitGraphDiagram-DS77QQ5N-t0HGYDel.js} +1 -1
  54. package/dist/worker/console/static/assets/index-B4l5ag79.js +407 -0
  55. package/dist/worker/console/static/assets/index-CwVHWB71.css +1 -0
  56. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-HG5z7aVg.js → infoDiagram-6WML65LV-WM0HZhe4.js} +1 -1
  57. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BHEu9chR.js → ishikawaDiagram-WSZJBQD7-CV5byHe0.js} +1 -1
  58. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-Cp_Up8-a.js → journeyDiagram-NVQOT4AX-lIyWkgnw.js} +1 -1
  59. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-DvVeY-em.js → kanban-definition-27J2QSJJ-BIH4YNRY.js} +1 -1
  60. package/dist/worker/console/static/assets/{linear-aGmcjgTn.js → linear-lQ0ZGrWc.js} +1 -1
  61. package/dist/worker/console/static/assets/{mermaid.core-Biyo-tqA.js → mermaid.core-BYdXtke8.js} +5 -5
  62. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-DyxY2pIb.js → mindmap-definition-FAOFIHXS-k93vkIa4.js} +1 -1
  63. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-CkdGuc50.js → pegDiagram-VL7TDLO6-Bm6AqvSy.js} +1 -1
  64. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-NNQI9xVR.js → pieDiagram-7S7Q4E2Y-DnJW6Xpf.js} +1 -1
  65. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-PAJkuz_G.js → quadrantDiagram-CIZ2JOQS-CMzm4ObL.js} +1 -1
  66. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-ZwrQod0O.js → railroadDiagram-AXF67PYL-fwFF5dFQ.js} +1 -1
  67. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-D3ZfrzM2.js → requirementDiagram-LRYGKXZP-tzl9VDUc.js} +1 -1
  68. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-DDGFmWk5.js → sankeyDiagram-W5VNT64P-B26V09DD.js} +1 -1
  69. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DO6mmXTA.js → sequenceDiagram-SI44F4Z6-D2npyCeP.js} +1 -1
  70. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DMbCQiJt.js → sizeCapture-X5ZJPWSS-CTJtkRU2.js} +1 -1
  71. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-Di_YUEfC.js → stateDiagram-OKZ733FA-B8lk4Lzu.js} +1 -1
  72. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BmOcMTJE.js +1 -0
  73. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-Bn2PphNJ.js → swimlanes-SLNWSIFB-C3yP53hH.js} +2 -2
  74. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Beah-dq0.js +8 -0
  75. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DPh8pRsp.js → timeline-definition-Z64GVDOM-Bck7xjhe.js} +1 -1
  76. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-GMfl26pW.js → vennDiagram-T6HMQDX7-CCntr1w7.js} +1 -1
  77. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DTlmVrmF.js → wardleyDiagram-T6FBY63Y-DsCqCBZz.js} +1 -1
  78. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-YGBjKAS5.js → xychartDiagram-ELKLHX3M-Br2l90OM.js} +1 -1
  79. package/dist/worker/console/static/index.html +2 -2
  80. package/dist/worker/console/static-src/app/console-types.js +29 -4
  81. package/dist/worker/console/static-src/app/useConsoleShell.js +30 -12
  82. package/dist/worker/console/static-src/app/useOperatorActions.js +36 -6
  83. package/dist/worker/console/static-src/app/useRecoveryConsole.js +10 -11
  84. package/dist/worker/console/static-src/app/useRunProgress.js +2 -1
  85. package/dist/worker/console/static-src/app/useTaskWizard.js +2 -2
  86. package/dist/worker/console/static-src/night/useNightBoard.js +8 -5
  87. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +140 -8
  88. package/dist/worker/console/static-src/operator-chat/details-dag-actions.js +122 -0
  89. package/dist/worker/console/static-src/operator-chat/refs.js +3 -0
  90. package/dist/worker/console/static-src/operator-chat/runtime-selection-labels.js +27 -0
  91. package/dist/worker/console/static-src/operator-chat/turn-submission.js +3 -2
  92. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +168 -72
  93. package/dist/worker/console/static-src/operator-chat/useChatStream.js +78 -17
  94. package/dist/worker/console/static-src/operator-chat/useChatThread.js +184 -4
  95. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +63 -9
  96. package/dist/worker/console/static-src/operator-chat/useWorkspaceBrowserGate.js +4 -0
  97. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +3 -3
  98. package/dist/worker/console/static-src/shell/useWorkspaces.js +299 -0
  99. package/dist/worker/console/static-src/shell/workspace-route.js +337 -0
  100. package/dist/worker/console/workspace-context.js +234 -0
  101. package/dist/worker/console/workspace-registry.js +214 -0
  102. package/dist/worker/observe/server.js +4 -0
  103. package/dist/worker/observe/static/api.js +26 -6
  104. package/dist/worker/observe/static/app.js +18 -70
  105. package/dist/worker/observe/static/index.html +1 -1
  106. package/dist/worker/observe/static/inspect-workspace.js +307 -0
  107. package/dist/worker/observe/static/operator-chrome.css +145 -20
  108. package/dist/worker/observe/static/operator-chrome.d.ts +20 -3
  109. package/dist/worker/observe/static/operator-chrome.js +330 -100
  110. package/dist/worker/observe/static/router.d.ts +46 -0
  111. package/dist/worker/observe/static/router.js +25 -1
  112. package/dist/worker/observe/static/state.d.ts +50 -0
  113. package/dist/worker/observe/static/state.js +172 -0
  114. package/dist/worker/observe/static/views/dag-inspector.js +3 -2
  115. package/dist/worker/observe/static/views/dag.js +2 -1
  116. package/dist/worker/observe/static/views/dashboard.js +6 -4
  117. package/dist/worker/observe/static/views/night.js +2 -2
  118. package/dist/worker/observe/static/views/pool.js +1 -0
  119. package/dist/worker/observe/static/views/run.js +3 -1
  120. package/dist/worker/observe/static/views/task.js +1 -1
  121. package/dist/workflows/dag/backend-test-pytest-collection.js +11 -10
  122. package/dist/workflows/dag/interrupt-request.js +3 -3
  123. package/dist/workflows/dag/lifecycle.js +3 -3
  124. package/docs/README.md +1 -0
  125. package/docs/operations/local-development-environment.md +21 -0
  126. package/harness.json +3 -3
  127. package/package.json +3 -1
  128. package/dist/worker/console/static/assets/channel-Dj_4Moei.js +0 -1
  129. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C0YYAyCa.js +0 -1
  130. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C0YYAyCa.js +0 -1
  131. package/dist/worker/console/static/assets/index-CW88Fjbw.js +0 -406
  132. package/dist/worker/console/static/assets/index-DP0ZJl39.css +0 -1
  133. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DrdDvela.js +0 -1
  134. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DPkEAULl.js +0 -8
@@ -0,0 +1,179 @@
1
+ import { aggregateUsage, } from "./usage.js";
2
+ function finiteNonNegative(value) {
3
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
4
+ ? value
5
+ : undefined;
6
+ }
7
+ function eventTime(event) {
8
+ const value = Date.parse(event.at);
9
+ return Number.isFinite(value) ? value : undefined;
10
+ }
11
+ function usageSample(data) {
12
+ if (!data || typeof data !== "object")
13
+ return undefined;
14
+ const source = data;
15
+ const sample = {};
16
+ const numberFields = [
17
+ "inputTokens",
18
+ "outputTokens",
19
+ "cacheReadTokens",
20
+ "cacheWriteTokens",
21
+ "totalTokens",
22
+ "cost",
23
+ ];
24
+ for (const field of numberFields) {
25
+ const value = finiteNonNegative(source[field]);
26
+ if (value !== undefined)
27
+ sample[field] = value;
28
+ }
29
+ if (typeof source.estimated === "boolean")
30
+ sample.estimated = source.estimated;
31
+ if (typeof source.responseKey === "string" && source.responseKey) {
32
+ sample.responseKey = source.responseKey;
33
+ }
34
+ return Object.keys(sample).length > 0 ? sample : undefined;
35
+ }
36
+ function contextUsage(data) {
37
+ if (!data || typeof data !== "object")
38
+ return undefined;
39
+ const raw = data.contextUsage;
40
+ if (!raw || typeof raw !== "object")
41
+ return undefined;
42
+ const source = raw;
43
+ const contextWindow = finiteNonNegative(source.contextWindow);
44
+ if (contextWindow === undefined)
45
+ return undefined;
46
+ const tokens = source.tokens === null ? null : finiteNonNegative(source.tokens) ?? null;
47
+ const percent = source.percent === null ? null : finiteNonNegative(source.percent) ?? null;
48
+ return { tokens, contextWindow, percent };
49
+ }
50
+ function stepTiming(data) {
51
+ if (!data || typeof data !== "object")
52
+ return undefined;
53
+ const raw = data.timing;
54
+ if (!raw || typeof raw !== "object")
55
+ return undefined;
56
+ const source = raw;
57
+ return {
58
+ llmMs: finiteNonNegative(source.llmMs),
59
+ ttftMs: finiteNonNegative(source.ttftMs),
60
+ decodeMs: finiteNonNegative(source.decodeMs),
61
+ outputTokens: finiteNonNegative(source.outputTokens),
62
+ };
63
+ }
64
+ /**
65
+ * Fold the durable event log into the same whole-session statistics DSH shows
66
+ * below its composer. Missing timing evidence is omitted instead of inferred.
67
+ */
68
+ export function deriveChatSessionTelemetry(events) {
69
+ const turns = new Set();
70
+ const stepStarts = new Map();
71
+ const toolStarts = new Map();
72
+ const usageSamples = [];
73
+ let latestContextUsage;
74
+ let steps = 0;
75
+ let llmMs = 0;
76
+ let toolMs = 0;
77
+ let ttftMs = 0;
78
+ let ttftSteps = 0;
79
+ let decodeMs = 0;
80
+ let decodeTokens = 0;
81
+ for (const event of events) {
82
+ const at = eventTime(event);
83
+ if (event.kind === "usage") {
84
+ const sample = usageSample(event.data);
85
+ if (sample)
86
+ usageSamples.push(sample);
87
+ latestContextUsage = contextUsage(event.data) ?? latestContextUsage;
88
+ continue;
89
+ }
90
+ if (event.kind === "agent_start") {
91
+ if (at !== undefined)
92
+ stepStarts.set(event.turnId, at);
93
+ continue;
94
+ }
95
+ if (event.kind === "tool_call") {
96
+ const toolCallId = event.data.toolCallId;
97
+ if (typeof toolCallId === "string" && toolCallId && at !== undefined) {
98
+ toolStarts.set(`${event.turnId}\u0000${toolCallId}`, at);
99
+ }
100
+ continue;
101
+ }
102
+ if (event.kind === "tool_result") {
103
+ const toolCallId = event.data.toolCallId;
104
+ if (typeof toolCallId === "string" && toolCallId && at !== undefined) {
105
+ const key = `${event.turnId}\u0000${toolCallId}`;
106
+ const startedAt = toolStarts.get(key);
107
+ if (startedAt !== undefined && at >= startedAt)
108
+ toolMs += at - startedAt;
109
+ toolStarts.delete(key);
110
+ // The next assistant step starts after the last completed tool.
111
+ stepStarts.set(event.turnId, at);
112
+ }
113
+ continue;
114
+ }
115
+ if (event.kind !== "message_end")
116
+ continue;
117
+ turns.add(event.turnId);
118
+ steps += 1;
119
+ const recorded = stepTiming(event.data);
120
+ const startedAt = stepStarts.get(event.turnId);
121
+ if (recorded?.llmMs !== undefined)
122
+ llmMs += recorded.llmMs;
123
+ else if (at !== undefined && startedAt !== undefined && at >= startedAt) {
124
+ llmMs += at - startedAt;
125
+ }
126
+ if (recorded?.ttftMs !== undefined) {
127
+ ttftMs += recorded.ttftMs;
128
+ ttftSteps += 1;
129
+ }
130
+ if (recorded?.decodeMs !== undefined &&
131
+ recorded.decodeMs > 0 &&
132
+ recorded.outputTokens !== undefined) {
133
+ decodeMs += recorded.decodeMs;
134
+ decodeTokens += recorded.outputTokens;
135
+ }
136
+ stepStarts.delete(event.turnId);
137
+ }
138
+ const usage = aggregateUsage(usageSamples);
139
+ return {
140
+ stats: {
141
+ turns: turns.size,
142
+ steps,
143
+ llmMs,
144
+ toolMs,
145
+ ttftMs,
146
+ ttftSteps,
147
+ decodeMs,
148
+ decodeTokens,
149
+ },
150
+ usage: {
151
+ ...usage,
152
+ ...(latestContextUsage ? { contextUsage: latestContextUsage } : {}),
153
+ },
154
+ };
155
+ }
156
+ export function billedInputTokens(usage) {
157
+ return usage.inputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
158
+ }
159
+ export function cacheHitPercent(usage) {
160
+ const billed = billedInputTokens(usage);
161
+ return billed > 0 ? Math.round((usage.cacheReadTokens / billed) * 100) : null;
162
+ }
163
+ export function formatSessionDuration(ms) {
164
+ const seconds = ms / 1_000;
165
+ if (seconds < 60)
166
+ return `${Math.round(seconds * 10) / 10}s`;
167
+ const whole = Math.round(seconds);
168
+ return `${Math.floor(whole / 60)}m${whole % 60}s`;
169
+ }
170
+ export function formatSessionTokens(tokens) {
171
+ const scaled = (value) => value >= 100
172
+ ? String(Math.round(value))
173
+ : String(Math.round(value * 10) / 10);
174
+ if (tokens < 1_000)
175
+ return String(tokens);
176
+ if (tokens < 1_000_000)
177
+ return `${scaled(tokens / 1_000)}K`;
178
+ return `${scaled(tokens / 1_000_000)}M`;
179
+ }
@@ -17,7 +17,7 @@ import { dispatchOperatorAction } from "../operator-actions.js";
17
17
  import { authorizeOperatorChatTool } from "./tools.js";
18
18
  import { projectOperationForChat } from "./chat-event-store.js";
19
19
  import { scrubSecrets } from "./explore-tools.js";
20
- import { hashDeferredTurnPayload, parseDeferredTurn, } from "./deferred-turn.js";
20
+ import { hashDeferredTurnPayload, parseDeferredTurns, } from "./deferred-turn.js";
21
21
  const CHAT_TITLE_MAX_CHARS = 80;
22
22
  /** Safe, one-line bounded title used for placeholders and model output. */
23
23
  export function cleanChatTitle(value) {
@@ -54,7 +54,13 @@ export class ChatSessionStore {
54
54
  const raw = await readJsonIfExists(recordPath(this.appData, sessionId));
55
55
  if (!raw || raw.sessionId !== sessionId)
56
56
  return undefined;
57
- const deferredTurn = parseDeferredTurn(raw.deferredTurn);
57
+ const deferredTurns = parseDeferredTurns(raw.deferredTurns);
58
+ const legacyDeferredTurn = parseDeferredTurns(raw.deferredTurn ? [raw.deferredTurn] : [])[0];
59
+ const normalizedDeferredTurns = deferredTurns.length > 0
60
+ ? deferredTurns
61
+ : legacyDeferredTurn
62
+ ? [legacyDeferredTurn]
63
+ : [];
58
64
  return {
59
65
  schemaVersion: 2,
60
66
  sessionId,
@@ -69,7 +75,14 @@ export class ChatSessionStore {
69
75
  ...(typeof raw.modelProvider === "string" ? { modelProvider: raw.modelProvider } : {}),
70
76
  ...(typeof raw.modelId === "string" ? { modelId: raw.modelId } : {}),
71
77
  ...(typeof raw.thinkingLevel === "string" ? { thinkingLevel: raw.thinkingLevel } : {}),
72
- ...(deferredTurn ? { deferredTurn } : {}),
78
+ ...(typeof raw.workspaceId === "string" && raw.workspaceId
79
+ ? { workspaceId: raw.workspaceId }
80
+ : {}),
81
+ deferredTurns: normalizedDeferredTurns,
82
+ ...(normalizedDeferredTurns[0] ? { deferredTurn: normalizedDeferredTurns[0] } : {}),
83
+ deferredQueueRevision: Number.isSafeInteger(raw.deferredQueueRevision) && (raw.deferredQueueRevision ?? 0) >= 0
84
+ ? raw.deferredQueueRevision
85
+ : 0,
73
86
  messages: Array.isArray(raw.messages) ? raw.messages : [],
74
87
  toolInvocations: Array.isArray(raw.toolInvocations) ? raw.toolInvocations : [],
75
88
  };
@@ -125,6 +138,7 @@ export class ChatSessionStore {
125
138
  repoFingerprint: this.appData.fingerprint,
126
139
  repoRootDisplay: this.appData.repoRoot,
127
140
  state: "active",
141
+ ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
128
142
  ...(input.sessionFile ? { sessionFile: input.sessionFile } : {}),
129
143
  createdAt: now,
130
144
  updatedAt: now,
@@ -150,7 +164,8 @@ export class ChatSessionStore {
150
164
  ...(input.targetSessionFile ? { sessionFile: input.targetSessionFile } : { sessionFile: undefined }),
151
165
  createdAt: now,
152
166
  updatedAt: now,
153
- deferredTurn: undefined,
167
+ deferredTurns: [],
168
+ deferredQueueRevision: 0,
154
169
  messages: structuredClone(source.messages),
155
170
  toolInvocations: structuredClone(source.toolInvocations),
156
171
  };
@@ -232,35 +247,21 @@ export class ChatSessionStore {
232
247
  return { ok: false, code: "INVALID_INPUT" };
233
248
  }
234
249
  const payloadHash = hashDeferredTurnPayload({ text, images: input.images });
235
- const existing = record.deferredTurn;
250
+ const queue = [...(record.deferredTurns ?? [])];
251
+ const existingIndex = queue.findIndex((item) => item.clientRequestId === clientRequestId);
252
+ const existing = existingIndex >= 0 ? queue[existingIndex] : undefined;
236
253
  if (existing) {
237
- if (existing.clientRequestId === clientRequestId) {
238
- if (existing.payloadHash !== payloadHash) {
239
- return {
240
- ok: false,
241
- code: "REQUEST_ID_REUSE_CONFLICT",
242
- deferredTurn: existing,
243
- };
244
- }
254
+ if (existing.payloadHash === payloadHash) {
245
255
  return { ok: true, deferredTurn: existing, idempotent: true };
246
256
  }
247
- if (existing.state !== "queued") {
248
- return {
249
- ok: false,
250
- code: "NOT_REPLACEABLE",
251
- deferredTurn: existing,
252
- };
257
+ if (existing.state !== "queued" && existing.state !== "failed") {
258
+ return { ok: false, code: "NOT_REPLACEABLE", deferredTurn: existing };
253
259
  }
254
260
  if (input.expectedRevision !== existing.revision) {
255
- return {
256
- ok: false,
257
- code: "REVISION_CONFLICT",
258
- deferredTurn: existing,
259
- };
261
+ return { ok: false, code: "REVISION_CONFLICT", deferredTurn: existing };
260
262
  }
261
263
  }
262
- else if (input.expectedRevision !== undefined &&
263
- input.expectedRevision !== 0) {
264
+ else if (input.expectedRevision !== undefined && input.expectedRevision !== 0) {
264
265
  return { ok: false, code: "REVISION_CONFLICT" };
265
266
  }
266
267
  const now = new Date().toISOString();
@@ -276,31 +277,41 @@ export class ChatSessionStore {
276
277
  createdAt: existing?.createdAt ?? now,
277
278
  updatedAt: now,
278
279
  };
279
- record.deferredTurn = deferredTurn;
280
+ if (existingIndex >= 0)
281
+ queue[existingIndex] = deferredTurn;
282
+ else
283
+ queue.push(deferredTurn);
284
+ record.deferredTurns = queue;
285
+ record.deferredTurn = queue[0];
286
+ record.deferredQueueRevision = (record.deferredQueueRevision ?? 0) + 1;
280
287
  record.updatedAt = now;
281
288
  await writeSecureJson(recordPath(this.appData, sessionId), record);
282
289
  return { ok: true, deferredTurn, idempotent: false };
283
290
  });
284
291
  }
285
- async claimDeferredTurn(sessionId, expectedRevision) {
292
+ async listDeferredTurns(sessionId) {
293
+ const record = await this.get(sessionId);
294
+ return record ? [...(record.deferredTurns ?? [])] : [];
295
+ }
296
+ async claimDeferredTurn(sessionId, expectedRevision, clientRequestId) {
286
297
  return this.serialize(sessionId, async () => {
287
298
  const record = await this.get(sessionId);
288
- const existing = record?.deferredTurn;
299
+ const queue = [...(record?.deferredTurns ?? [])];
300
+ const index = clientRequestId
301
+ ? queue.findIndex((item) => item.clientRequestId === clientRequestId)
302
+ : queue.findIndex((item) => item.state === "queued");
303
+ const existing = index >= 0 ? queue[index] : undefined;
289
304
  if (!record || !existing)
290
305
  return { ok: false, code: "NOT_FOUND" };
291
- if (existing.revision !== expectedRevision) {
306
+ if (existing.revision !== expectedRevision)
292
307
  return { ok: false, code: "REVISION_CONFLICT", deferredTurn: existing };
293
- }
294
- if (existing.state !== "queued") {
308
+ if (existing.state !== "queued")
295
309
  return { ok: false, code: "NOT_CLAIMABLE", deferredTurn: existing };
296
- }
297
- const next = {
298
- ...existing,
299
- revision: existing.revision + 1,
300
- state: "dispatching",
301
- updatedAt: new Date().toISOString(),
302
- };
303
- record.deferredTurn = next;
310
+ const next = { ...existing, revision: existing.revision + 1, state: "dispatching", updatedAt: new Date().toISOString() };
311
+ queue[index] = next;
312
+ record.deferredTurns = queue;
313
+ record.deferredTurn = queue[0];
314
+ record.deferredQueueRevision = (record.deferredQueueRevision ?? 0) + 1;
304
315
  record.updatedAt = next.updatedAt;
305
316
  await writeSecureJson(recordPath(this.appData, sessionId), record);
306
317
  return { ok: true, deferredTurn: next };
@@ -309,20 +320,20 @@ export class ChatSessionStore {
309
320
  async transitionDeferredTurn(sessionId, input) {
310
321
  return this.serialize(sessionId, async () => {
311
322
  const record = await this.get(sessionId);
312
- const existing = record?.deferredTurn;
323
+ const queue = [...(record?.deferredTurns ?? [])];
324
+ const index = input.clientRequestId
325
+ ? queue.findIndex((item) => item.clientRequestId === input.clientRequestId)
326
+ : queue.findIndex((item) => item.revision === input.expectedRevision);
327
+ const existing = index >= 0 ? queue[index] : undefined;
313
328
  if (!record || !existing || existing.revision !== input.expectedRevision)
314
329
  return undefined;
315
- const next = {
316
- ...existing,
317
- revision: existing.revision + 1,
318
- state: input.state,
319
- updatedAt: new Date().toISOString(),
320
- ...(input.acceptedTurnId ? { acceptedTurnId: input.acceptedTurnId } : {}),
321
- ...(input.lastError ? { lastError: input.lastError } : {}),
322
- };
330
+ const next = { ...existing, revision: existing.revision + 1, state: input.state, updatedAt: new Date().toISOString(), ...(input.acceptedTurnId ? { acceptedTurnId: input.acceptedTurnId } : {}), ...(input.lastError ? { lastError: input.lastError } : {}) };
323
331
  if (!input.lastError)
324
332
  delete next.lastError;
325
- record.deferredTurn = next;
333
+ queue[index] = next;
334
+ record.deferredTurns = queue;
335
+ record.deferredTurn = queue[0];
336
+ record.deferredQueueRevision = (record.deferredQueueRevision ?? 0) + 1;
326
337
  record.updatedAt = next.updatedAt;
327
338
  await writeSecureJson(recordPath(this.appData, sessionId), record);
328
339
  return next;
@@ -331,17 +342,19 @@ export class ChatSessionStore {
331
342
  async deleteDeferredTurn(sessionId, input) {
332
343
  return this.serialize(sessionId, async () => {
333
344
  const record = await this.get(sessionId);
334
- const existing = record?.deferredTurn;
335
- if (!record || !existing || existing.clientRequestId !== input.clientRequestId) {
345
+ const queue = [...(record?.deferredTurns ?? [])];
346
+ const index = queue.findIndex((item) => item.clientRequestId === input.clientRequestId);
347
+ const existing = index >= 0 ? queue[index] : undefined;
348
+ if (!record || !existing)
336
349
  return { ok: false, code: "NOT_FOUND" };
337
- }
338
- if (existing.state !== "queued" && existing.state !== "failed") {
350
+ if (existing.state !== "queued" && existing.state !== "failed")
339
351
  return { ok: false, code: "NOT_CANCELLABLE", deferredTurn: existing };
340
- }
341
- if (existing.revision !== input.expectedRevision) {
352
+ if (existing.revision !== input.expectedRevision)
342
353
  return { ok: false, code: "REVISION_CONFLICT", deferredTurn: existing };
343
- }
344
- delete record.deferredTurn;
354
+ queue.splice(index, 1);
355
+ record.deferredTurns = queue;
356
+ record.deferredTurn = queue[0];
357
+ record.deferredQueueRevision = (record.deferredQueueRevision ?? 0) + 1;
345
358
  record.updatedAt = new Date().toISOString();
346
359
  await writeSecureJson(recordPath(this.appData, sessionId), record);
347
360
  return { ok: true, deferredTurn: existing };
@@ -350,11 +363,15 @@ export class ChatSessionStore {
350
363
  async clearDeferredTurnForRequest(sessionId, clientRequestId) {
351
364
  return this.serialize(sessionId, async () => {
352
365
  const record = await this.get(sessionId);
353
- const existing = record?.deferredTurn;
354
- if (!record || !existing || existing.clientRequestId !== clientRequestId) {
366
+ const queue = [...(record?.deferredTurns ?? [])];
367
+ const index = queue.findIndex((item) => item.clientRequestId === clientRequestId);
368
+ const existing = index >= 0 ? queue[index] : undefined;
369
+ if (!record || !existing)
355
370
  return undefined;
356
- }
357
- delete record.deferredTurn;
371
+ queue.splice(index, 1);
372
+ record.deferredTurns = queue;
373
+ record.deferredTurn = queue[0];
374
+ record.deferredQueueRevision = (record.deferredQueueRevision ?? 0) + 1;
358
375
  record.updatedAt = new Date().toISOString();
359
376
  await writeSecureJson(recordPath(this.appData, sessionId), record);
360
377
  return { deferredTurn: existing, clearedRevision: existing.revision + 1 };
@@ -561,9 +578,22 @@ export async function runChatTurn(options) {
561
578
  }
562
579
  case "error":
563
580
  turnError = { code: "CHAT_TURN_ERROR", message: event.message };
581
+ pending.push(store.appendMessage(sessionId, {
582
+ role: "assistant",
583
+ kind: "provider-error",
584
+ text: event.providerError.message,
585
+ providerError: event.providerError,
586
+ ...(event.content && event.content.length > 0
587
+ ? { content: event.content }
588
+ : {}),
589
+ }));
564
590
  break;
565
591
  }
566
- }, { signal: options.signal, images: options.images });
592
+ }, {
593
+ signal: options.signal,
594
+ images: options.images,
595
+ ...(options.turnId ? { turnId: options.turnId } : {}),
596
+ });
567
597
  }
568
598
  catch (error) {
569
599
  await Promise.allSettled(pending);
@@ -0,0 +1,115 @@
1
+ export function normalizeTodos(raw) {
2
+ if (!Array.isArray(raw))
3
+ throw new Error("todos must be an array");
4
+ const seen = new Set();
5
+ return raw.map((item, index) => {
6
+ if (!item || typeof item !== "object")
7
+ throw new Error(`todo item ${index + 1} must be an object`);
8
+ const value = item;
9
+ const keys = Object.keys(value);
10
+ if (keys.some((key) => key !== "content" && key !== "status"))
11
+ throw new Error(`todo item ${index + 1} contains unknown fields`);
12
+ const content = typeof value.content === "string" ? value.content.trim() : "";
13
+ if (!content)
14
+ throw new Error(`todo item ${index + 1} content must not be empty`);
15
+ if (seen.has(content))
16
+ throw new Error(`duplicate todo content: ${content}`);
17
+ seen.add(content);
18
+ if (value.status !== "pending" && value.status !== "in_progress" && value.status !== "completed")
19
+ throw new Error(`todo item ${index + 1} has invalid status`);
20
+ return { content, status: value.status };
21
+ });
22
+ }
23
+ export function countTodos(todos) {
24
+ return {
25
+ pending: todos.filter((todo) => todo.status === "pending").length,
26
+ inProgress: todos.filter((todo) => todo.status === "in_progress").length,
27
+ completed: todos.filter((todo) => todo.status === "completed").length,
28
+ };
29
+ }
30
+ export class TodoListRegistry {
31
+ current = new Map();
32
+ turns = new Map();
33
+ listeners = new Set();
34
+ subscribe(listener) {
35
+ this.listeners.add(listener);
36
+ return () => this.listeners.delete(listener);
37
+ }
38
+ startTurn(sessionId, turnId) {
39
+ this.turns.set(sessionId, turnId);
40
+ this.current.delete(sessionId);
41
+ this.publish({ type: "cleared", sessionId, turnId, todos: [] });
42
+ }
43
+ write(sessionId, toolCallId, raw) {
44
+ const todos = normalizeTodos(raw);
45
+ this.current.set(sessionId, todos);
46
+ this.publish({
47
+ type: "updated",
48
+ sessionId,
49
+ turnId: this.turns.get(sessionId),
50
+ toolCallId,
51
+ todos,
52
+ });
53
+ return { todos, counts: countTodos(todos) };
54
+ }
55
+ get(sessionId) {
56
+ const todos = this.current.get(sessionId);
57
+ return todos ? todos.map((todo) => ({ ...todo })) : null;
58
+ }
59
+ clear(sessionId) {
60
+ this.current.delete(sessionId);
61
+ this.turns.delete(sessionId);
62
+ }
63
+ publish(change) {
64
+ for (const listener of this.listeners) {
65
+ try {
66
+ listener(change);
67
+ }
68
+ catch { /* observers must not break tool execution */ }
69
+ }
70
+ }
71
+ }
72
+ export function projectCurrentTodos(events) {
73
+ let current = null;
74
+ for (const event of events) {
75
+ if (event.kind === "todo_cleared")
76
+ current = null;
77
+ if (event.kind === "todo_updated") {
78
+ const data = event.data;
79
+ try {
80
+ current = normalizeTodos(data.todos);
81
+ }
82
+ catch { /* ignore malformed historical data */ }
83
+ }
84
+ }
85
+ return current;
86
+ }
87
+ export function buildTodoCompactionInstructions(rawInstructions, todos) {
88
+ const todoReminder = todos && todos.length > 0
89
+ ? [
90
+ "Preserve the current Todo plan in the compaction summary and continuation context. Do not drop or invent tasks. Current whole-list state:",
91
+ JSON.stringify(todos),
92
+ "Keep each item's content and status (pending, in_progress, completed).",
93
+ ].join("\n")
94
+ : undefined;
95
+ return [rawInstructions?.trim(), todoReminder]
96
+ .filter((value) => Boolean(value))
97
+ .join("\n\n") || undefined;
98
+ }
99
+ /**
100
+ * Ephemeral model context used after a successful Pi compaction in the same
101
+ * turn. The Todo event remains the durable/UI source of truth; this text only
102
+ * prevents the compacted continuation from losing the current whole-list
103
+ * state. A later turn/start clears the registry, matching DSH's lifetime.
104
+ */
105
+ export function buildTodoContinuationContext(todos) {
106
+ if (!todos || todos.length === 0)
107
+ return undefined;
108
+ return [
109
+ "<current_todo_plan>",
110
+ "Continue the active turn using this exact current Todo whole-list state after context compaction. Do not drop, rename, or invent tasks.",
111
+ JSON.stringify(todos),
112
+ "Preserve each item's content and status (pending, in_progress, completed).",
113
+ "</current_todo_plan>",
114
+ ].join("\n");
115
+ }
@@ -141,6 +141,48 @@ export function getToolPreview(args, options) {
141
141
  return "";
142
142
  return s.length > maxLen ? `${s.slice(0, maxLen)}…` : s;
143
143
  }
144
+ /** Stable DSH-style tool row identity; unknown tools keep their real name. */
145
+ export function getToolPresentation(toolName) {
146
+ const raw = toolName?.trim() || "Tool";
147
+ const name = raw.toLowerCase();
148
+ if (name === "bash" || name === "shell" || name.includes("exec_command")) {
149
+ return { kind: "bash", label: "Bash" };
150
+ }
151
+ if (name === "read" ||
152
+ name === "read_file" ||
153
+ name.startsWith("read_") ||
154
+ name.includes("view_file")) {
155
+ return { kind: "read", label: "Read" };
156
+ }
157
+ if (name === "search" ||
158
+ name === "grep" ||
159
+ name === "glob" ||
160
+ name.includes("search") ||
161
+ name.includes("codegraph") ||
162
+ name.includes("lsp")) {
163
+ return { kind: "search", label: name === "glob" ? "Glob" : "Search" };
164
+ }
165
+ if (name === "write" ||
166
+ name === "edit" ||
167
+ name.includes("apply_patch") ||
168
+ name.includes("write_file") ||
169
+ name.includes("edit_file")) {
170
+ return {
171
+ kind: "write",
172
+ label: name === "write" || name.includes("write_file") ? "Write" : "Edit",
173
+ };
174
+ }
175
+ if (name === "web" ||
176
+ name.includes("browser") ||
177
+ name.includes("fetch") ||
178
+ name.includes("web_")) {
179
+ return { kind: "web", label: "Web" };
180
+ }
181
+ if (name === "mcp" || name.startsWith("mcp_") || name.includes("__")) {
182
+ return { kind: "mcp", label: name === "mcp" ? "MCP" : raw };
183
+ }
184
+ return { kind: "tool", label: raw };
185
+ }
144
186
  /** Section labels for ToolCard expand body. */
145
187
  export function getToolSectionLabels(toolName) {
146
188
  const name = (toolName ?? "").toLowerCase();