@keith9681/dsh-sidebar 0.15.2

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 (220) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +0 -0
  3. package/README_EN.md +0 -0
  4. package/cordis.patch.yml +49 -0
  5. package/lib/client-editor.js +34651 -0
  6. package/lib/client-mermaid.js +200629 -0
  7. package/lib/client-registry.js +13388 -0
  8. package/lib/client-terminal.js +8904 -0
  9. package/lib/client.js +13388 -0
  10. package/lib/index.js +3959 -0
  11. package/lib/invariant.js +22 -0
  12. package/lib/types/agent-pty.d.ts +226 -0
  13. package/lib/types/browser-probe.d.ts +15 -0
  14. package/lib/types/bundle-route.d.ts +12 -0
  15. package/lib/types/client/BrowserView.d.ts +22 -0
  16. package/lib/types/client/DiffTab.d.ts +6 -0
  17. package/lib/types/client/DiffView.d.ts +51 -0
  18. package/lib/types/client/EditorHost.d.ts +12 -0
  19. package/lib/types/client/FileTree.d.ts +36 -0
  20. package/lib/types/client/GitView.d.ts +8 -0
  21. package/lib/types/client/OrphanedTab.d.ts +11 -0
  22. package/lib/types/client/PdfView.d.ts +6 -0
  23. package/lib/types/client/RenderBoundary.d.ts +32 -0
  24. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  25. package/lib/types/client/SideCardSection.d.ts +74 -0
  26. package/lib/types/client/SideChatView.d.ts +16 -0
  27. package/lib/types/client/Sidebar.d.ts +6 -0
  28. package/lib/types/client/SubagentView.d.ts +16 -0
  29. package/lib/types/client/TabBar.d.ts +43 -0
  30. package/lib/types/client/TerminalView.d.ts +24 -0
  31. package/lib/types/client/TextEditor.d.ts +10 -0
  32. package/lib/types/client/TreePanel.d.ts +23 -0
  33. package/lib/types/client/UploadOverlay.d.ts +20 -0
  34. package/lib/types/client/add-plugin-modal.d.ts +15 -0
  35. package/lib/types/client/api.d.ts +268 -0
  36. package/lib/types/client/binary-download.d.ts +12 -0
  37. package/lib/types/client/breakpoints.d.ts +12 -0
  38. package/lib/types/client/browser.d.ts +50 -0
  39. package/lib/types/client/builtins/index.d.ts +18 -0
  40. package/lib/types/client/builtins/tabs.d.ts +11 -0
  41. package/lib/types/client/builtins/viewers.d.ts +3 -0
  42. package/lib/types/client/chunk-loader.d.ts +124 -0
  43. package/lib/types/client/chunks/editor.d.ts +9 -0
  44. package/lib/types/client/chunks/mermaid.d.ts +10 -0
  45. package/lib/types/client/chunks/terminal.d.ts +9 -0
  46. package/lib/types/client/cm-themes.d.ts +24 -0
  47. package/lib/types/client/conversation-draft.d.ts +14 -0
  48. package/lib/types/client/desktop-env.d.ts +35 -0
  49. package/lib/types/client/editable-state.d.ts +27 -0
  50. package/lib/types/client/editor-load.d.ts +66 -0
  51. package/lib/types/client/frame-batcher.d.ts +24 -0
  52. package/lib/types/client/icons.d.ts +57 -0
  53. package/lib/types/client/image-types.d.ts +3 -0
  54. package/lib/types/client/ime-guard.d.ts +36 -0
  55. package/lib/types/client/index.d.ts +19 -0
  56. package/lib/types/client/intercept.d.ts +33 -0
  57. package/lib/types/client/lang.d.ts +12 -0
  58. package/lib/types/client/lazy-chunk.d.ts +25 -0
  59. package/lib/types/client/link-intercept.d.ts +42 -0
  60. package/lib/types/client/locales.d.ts +366 -0
  61. package/lib/types/client/mermaid-blocks.d.ts +41 -0
  62. package/lib/types/client/mermaid-sanitize.d.ts +2 -0
  63. package/lib/types/client/mermaid.d.ts +12 -0
  64. package/lib/types/client/open-when-sized.d.ts +20 -0
  65. package/lib/types/client/open-with-settings.d.ts +4 -0
  66. package/lib/types/client/open-with.d.ts +87 -0
  67. package/lib/types/client/openpath-intercept.d.ts +41 -0
  68. package/lib/types/client/paths.d.ts +35 -0
  69. package/lib/types/client/pdf-types.d.ts +2 -0
  70. package/lib/types/client/plugin-settings.d.ts +8 -0
  71. package/lib/types/client/plugins-shared.d.ts +27 -0
  72. package/lib/types/client/plugins-tabs.d.ts +3 -0
  73. package/lib/types/client/plugins-viewers.d.ts +3 -0
  74. package/lib/types/client/prefs.d.ts +41 -0
  75. package/lib/types/client/produced-files.d.ts +23 -0
  76. package/lib/types/client/selection-payload.d.ts +27 -0
  77. package/lib/types/client/service.d.ts +440 -0
  78. package/lib/types/client/settings-nav-icon.d.ts +19 -0
  79. package/lib/types/client/shell-presets.d.ts +48 -0
  80. package/lib/types/client/sidechat-transcript.d.ts +103 -0
  81. package/lib/types/client/split-pane.d.ts +30 -0
  82. package/lib/types/client/state.d.ts +359 -0
  83. package/lib/types/client/subagent-detect.d.ts +54 -0
  84. package/lib/types/client/subagent-jobs.d.ts +63 -0
  85. package/lib/types/client/tab-content-memo.d.ts +33 -0
  86. package/lib/types/client/terminal-font.d.ts +20 -0
  87. package/lib/types/client/theme.d.ts +45 -0
  88. package/lib/types/client/titlebar-strip.d.ts +23 -0
  89. package/lib/types/client/upload.d.ts +69 -0
  90. package/lib/types/client/wco.d.ts +47 -0
  91. package/lib/types/config.d.ts +64 -0
  92. package/lib/types/context-types.d.ts +603 -0
  93. package/lib/types/fs-operations.d.ts +28 -0
  94. package/lib/types/fs-search.d.ts +24 -0
  95. package/lib/types/fs-tree.d.ts +51 -0
  96. package/lib/types/git.d.ts +82 -0
  97. package/lib/types/html-route.d.ts +59 -0
  98. package/lib/types/index.d.ts +47 -0
  99. package/lib/types/invariant.d.ts +15 -0
  100. package/lib/types/jobs-routes.d.ts +45 -0
  101. package/lib/types/open-external.d.ts +24 -0
  102. package/lib/types/prefs-shared.d.ts +225 -0
  103. package/lib/types/pty-deps.d.ts +78 -0
  104. package/lib/types/pty-manager.d.ts +168 -0
  105. package/lib/types/sidechat-core.d.ts +172 -0
  106. package/lib/types/sidechat-routes.d.ts +30 -0
  107. package/lib/types/subagent-activity.d.ts +44 -0
  108. package/lib/types/subagent-live-route.d.ts +44 -0
  109. package/lib/types/tools.d.ts +31 -0
  110. package/lib/types/trust-fence.d.ts +24 -0
  111. package/lib/types/wire.d.ts +38 -0
  112. package/package.json +173 -0
  113. package/scripts/install.ps1 +289 -0
  114. package/scripts/install.sh +287 -0
  115. package/src/agent-pty.ts +527 -0
  116. package/src/browser-probe.ts +26 -0
  117. package/src/bundle-route.ts +129 -0
  118. package/src/client/BrowserView.tsx +253 -0
  119. package/src/client/DiffTab.tsx +110 -0
  120. package/src/client/DiffView.tsx +305 -0
  121. package/src/client/EditorHost.tsx +525 -0
  122. package/src/client/FileTree.tsx +653 -0
  123. package/src/client/GitView.tsx +558 -0
  124. package/src/client/OrphanedTab.tsx +27 -0
  125. package/src/client/PdfView.tsx +110 -0
  126. package/src/client/RenderBoundary.tsx +49 -0
  127. package/src/client/SandboxStatusBar.tsx +60 -0
  128. package/src/client/SideCardSection.module.css +960 -0
  129. package/src/client/SideCardSection.tsx +1152 -0
  130. package/src/client/SideChatView.module.css +437 -0
  131. package/src/client/SideChatView.tsx +644 -0
  132. package/src/client/Sidebar.tsx +1148 -0
  133. package/src/client/SubagentView.module.css +547 -0
  134. package/src/client/SubagentView.tsx +883 -0
  135. package/src/client/TabBar.tsx +305 -0
  136. package/src/client/TerminalView.tsx +383 -0
  137. package/src/client/TextEditor.tsx +500 -0
  138. package/src/client/TreePanel.tsx +273 -0
  139. package/src/client/UploadOverlay.tsx +62 -0
  140. package/src/client/add-plugin-modal.tsx +199 -0
  141. package/src/client/api.ts +335 -0
  142. package/src/client/binary-download.tsx +23 -0
  143. package/src/client/breakpoints.ts +51 -0
  144. package/src/client/browser.ts +119 -0
  145. package/src/client/builtins/index.ts +37 -0
  146. package/src/client/builtins/tabs.tsx +331 -0
  147. package/src/client/builtins/viewers.tsx +125 -0
  148. package/src/client/chunk-loader.ts +351 -0
  149. package/src/client/chunks/editor.tsx +9 -0
  150. package/src/client/chunks/mermaid.tsx +10 -0
  151. package/src/client/chunks/terminal.tsx +9 -0
  152. package/src/client/cm-themes.ts +129 -0
  153. package/src/client/conversation-draft.ts +29 -0
  154. package/src/client/css-modules.d.ts +5 -0
  155. package/src/client/desktop-env.ts +72 -0
  156. package/src/client/editable-state.ts +70 -0
  157. package/src/client/editor-load.ts +92 -0
  158. package/src/client/frame-batcher.ts +56 -0
  159. package/src/client/icons.tsx +161 -0
  160. package/src/client/image-types.ts +8 -0
  161. package/src/client/ime-guard.ts +47 -0
  162. package/src/client/index.tsx +369 -0
  163. package/src/client/intercept.tsx +110 -0
  164. package/src/client/lang.ts +103 -0
  165. package/src/client/layout.css +94 -0
  166. package/src/client/lazy-chunk.tsx +89 -0
  167. package/src/client/link-intercept.ts +73 -0
  168. package/src/client/locales.ts +734 -0
  169. package/src/client/mermaid-blocks.ts +110 -0
  170. package/src/client/mermaid-sanitize.ts +93 -0
  171. package/src/client/mermaid.tsx +401 -0
  172. package/src/client/open-when-sized.ts +43 -0
  173. package/src/client/open-with-settings.tsx +135 -0
  174. package/src/client/open-with.ts +221 -0
  175. package/src/client/openpath-intercept.ts +62 -0
  176. package/src/client/paths.ts +47 -0
  177. package/src/client/pdf-types.ts +4 -0
  178. package/src/client/plugin-settings.ts +43 -0
  179. package/src/client/plugins-shared.ts +29 -0
  180. package/src/client/plugins-tabs.ts +87 -0
  181. package/src/client/plugins-viewers.ts +30 -0
  182. package/src/client/prefs.ts +207 -0
  183. package/src/client/produced-files.ts +91 -0
  184. package/src/client/selection-payload.ts +86 -0
  185. package/src/client/service.ts +838 -0
  186. package/src/client/settings-nav-icon.ts +45 -0
  187. package/src/client/shell-presets.ts +83 -0
  188. package/src/client/sidebar.module.css +2577 -0
  189. package/src/client/sidechat-transcript.ts +321 -0
  190. package/src/client/split-pane.tsx +322 -0
  191. package/src/client/state.ts +1175 -0
  192. package/src/client/subagent-detect.ts +137 -0
  193. package/src/client/subagent-jobs.ts +161 -0
  194. package/src/client/tab-content-memo.ts +46 -0
  195. package/src/client/terminal-font.ts +28 -0
  196. package/src/client/theme.ts +110 -0
  197. package/src/client/titlebar-strip.ts +37 -0
  198. package/src/client/upload.ts +187 -0
  199. package/src/client/wco.ts +120 -0
  200. package/src/config.ts +155 -0
  201. package/src/context-types.ts +610 -0
  202. package/src/fs-operations.ts +99 -0
  203. package/src/fs-search.ts +86 -0
  204. package/src/fs-tree.ts +158 -0
  205. package/src/git.ts +244 -0
  206. package/src/html-route.ts +106 -0
  207. package/src/index.ts +1132 -0
  208. package/src/invariant.ts +32 -0
  209. package/src/jobs-routes.ts +257 -0
  210. package/src/open-external.ts +90 -0
  211. package/src/prefs-shared.ts +268 -0
  212. package/src/pty-deps.ts +240 -0
  213. package/src/pty-manager.ts +376 -0
  214. package/src/sidechat-core.ts +468 -0
  215. package/src/sidechat-routes.ts +344 -0
  216. package/src/subagent-activity.ts +91 -0
  217. package/src/subagent-live-route.ts +93 -0
  218. package/src/tools.ts +479 -0
  219. package/src/trust-fence.ts +77 -0
  220. package/src/wire.ts +100 -0
@@ -0,0 +1,468 @@
1
+ /**
2
+ * Pure side-conversation ("Side Chat") logic shared by the host routes and
3
+ * the client tab. Framework-free (no React, no Node) so both halves and the
4
+ * node test environment can import it.
5
+ *
6
+ * A side thread is a child session the plugin creates ITSELF with a custom
7
+ * seed — the parent session's FULL event log up to the click moment
8
+ * (completed turns, the unanswered user message, and — when the parent is
9
+ * mid-turn — the in-progress assistant output and tool activity). The log
10
+ * model forbids open-turn seeds, so an in-flight parent turn is copied
11
+ * verbatim and CLOSED with synthetic `step/end` + `turn/end{reason:
12
+ * 'interrupted'}` events: the child sees the partial turn as honestly
13
+ * frozen ("cut off"), never as a completed answer. The one case that cannot
14
+ * be closed honestly — a tool call still executing (no `tool/result` yet;
15
+ * providers reject dangling assistant calls) — falls back to cutting before
16
+ * the open turn and carrying the partial content as a structured text
17
+ * snapshot inside the boundary prompt.
18
+ */
19
+ import type { SidebarHistoryEntry, SidebarSessionSummary } from './context-types.ts'
20
+
21
+ /** The durable thread-label prefix (also the row filter in the client list). */
22
+ export const SIDE_LABEL_PREFIX = 'Side: '
23
+
24
+ /** The pinned label of a freshly created thread that no prompt has reached
25
+ * yet (Codex-style immediate create: the tab opens an EMPTY thread, the
26
+ * first composer message carries the boundary and earns the real label).
27
+ * The client renders it localized; the prefix keeps the row filter honest. */
28
+ export const SIDE_NEW_THREAD_TITLE = 'Side: New thread'
29
+
30
+ /** Maximum code points kept in a durable thread label (matches subagent labels). */
31
+ export const LABEL_MAX_CHARS = 48
32
+
33
+ /** The boundary message's opening line — the transcript mapping drops user
34
+ * rows starting with it (same first line as dsh-sidechain's boundary, so
35
+ * the two plugins' threads render consistently in either UI). */
36
+ export const SIDE_BOUNDARY_PREFIX = 'Side conversation boundary'
37
+
38
+ /** The plugin identity stamped on the source of context-injection messages
39
+ * (boundary prompt + parked snapshot), so the transcript recognizes them
40
+ * structurally — not by text prefix. */
41
+ export const SIDE_INJECTION_PLUGIN = 'dsh-better-sidebar'
42
+
43
+ /**
44
+ * The boundary prompt delivered as the thread's first user message: the
45
+ * inherited seed is reference context only, never active instruction.
46
+ * Model-facing contract — change only with intent, tests pin the sentences.
47
+ */
48
+ export const SIDE_BOUNDARY_PROMPT = `Side conversation boundary.
49
+
50
+ Everything before this boundary is inherited history from the parent session: its completed turns, its pending question, and — if the parent was mid-turn — its in-progress output frozen at the moment this side conversation started. It is reference context only. It is not your current task.
51
+
52
+ Do not continue, execute, or complete any instructions, plans, tool calls, approvals, edits, or requests from before this boundary. Only messages submitted after this boundary are active user instructions for this side conversation.
53
+
54
+ Mode: this is a continuable side conversation. Your answers stay in this side thread and are viewed in the side panel; they are never delivered into the parent session.`
55
+
56
+ /** One seed event (structural mirror of the durable SessionEvent). The
57
+ * envelope fields are preserved verbatim: surface-eligible events
58
+ * (user/message, assistant/message, tool/result) REQUIRE the `surfaceOp`
59
+ * marker (and may carry `sourceEventSeqs`) — the seed validator rejects
60
+ * them without it. */
61
+ export interface SeedEvent {
62
+ type: string
63
+ seq: number
64
+ time: number
65
+ data: Record<string, unknown>
66
+ /** Surface marker of message-producing events ('append' | replace op). */
67
+ surfaceOp?: unknown
68
+ /** Seq numbers of earlier events this event cites as sources. */
69
+ sourceEventSeqs?: unknown
70
+ /** Reader-skip marker of purely informational events. */
71
+ ignorable?: true
72
+ }
73
+
74
+ /** The minimal structural face of a session-log event this module reads
75
+ * (loose enough to accept both the host's real SessionEvent and the
76
+ * client's SidebarSessionEvent mirror). */
77
+ export interface SidechatLogEvent {
78
+ type: string
79
+ seq: number
80
+ time: number
81
+ data: unknown
82
+ }
83
+
84
+ /** The result of cutting a parent log into a side-thread inheritance. */
85
+ export interface SidechatInheritance {
86
+ /** The child seed: contiguous from seq 0, ends outside any open turn. */
87
+ seed: SeedEvent[]
88
+ /**
89
+ * Structured snapshot of the parent's in-progress turn when it could NOT
90
+ * be included as events (a tool call was still executing); null when the
91
+ * seed already carries the whole picture.
92
+ */
93
+ snapshot: string | null
94
+ }
95
+
96
+ /** The data record of one event (narrowed from the loose face). */
97
+ function dataOf(event: SidechatLogEvent): Record<string, unknown> {
98
+ return event.data as Record<string, unknown>
99
+ }
100
+
101
+ /** Copy parent events verbatim (their live seq === array index contract).
102
+ * The FULL envelope is preserved — stripping `surfaceOp` would make the
103
+ * seed validator reject every surface-eligible message event. */
104
+ function copyEvents(events: readonly SidechatLogEvent[]): SeedEvent[] {
105
+ return events.map(event => {
106
+ const source = event as SidechatLogEvent & {
107
+ surfaceOp?: unknown
108
+ sourceEventSeqs?: unknown
109
+ ignorable?: true
110
+ }
111
+ return {
112
+ type: source.type,
113
+ seq: source.seq,
114
+ time: source.time,
115
+ data: dataOf(source),
116
+ ...(source.surfaceOp === undefined ? {} : { surfaceOp: source.surfaceOp }),
117
+ ...(source.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: source.sourceEventSeqs }),
118
+ ...(source.ignorable === undefined ? {} : { ignorable: source.ignorable }),
119
+ }
120
+ })
121
+ }
122
+
123
+ /** Index of the last `turn/start` or `turn/end`, or -1. */
124
+ function lastTurnBoundary(events: readonly SidechatLogEvent[]): number {
125
+ for (let index = events.length - 1; index >= 0; index--) {
126
+ const type = events[index]?.type
127
+ if (type === 'turn/start' || type === 'turn/end') return index
128
+ }
129
+ return -1
130
+ }
131
+
132
+ /** Numeric field of an event's data (turn / step numbers). */
133
+ function numberAt(data: Record<string, unknown>, key: string): number {
134
+ const value = data[key]
135
+ return typeof value === 'number' && Number.isSafeInteger(value) ? value : 0
136
+ }
137
+
138
+ /** The step number still open at the log tail inside the turn starting at
139
+ * `turnStart` (undefined when no step is open). */
140
+ function openStepInTurn(events: readonly SidechatLogEvent[], turnStart: number): number | undefined {
141
+ let open: number | undefined
142
+ for (let index = turnStart + 1; index < events.length; index++) {
143
+ const event = events[index]
144
+ if (event === undefined) continue
145
+ if (event.type === 'step/start') open = numberAt(dataOf(event), 'step')
146
+ else if (event.type === 'step/end') open = undefined
147
+ }
148
+ return open
149
+ }
150
+
151
+ /**
152
+ * Whether the open turn ending the log has a `tool/call` without its paired
153
+ * `tool/result` in the CURRENT open step. Providers reject dangling
154
+ * assistant calls, so such a turn cannot be honestly closed and the
155
+ * inheritance must fall back to the snapshot.
156
+ */
157
+ export function hasDanglingToolCall(events: readonly SidechatLogEvent[], turnStart: number): boolean {
158
+ const pending = new Set<string>()
159
+ for (let index = turnStart + 1; index < events.length; index++) {
160
+ const event = events[index]
161
+ if (event === undefined) continue
162
+ const data = dataOf(event)
163
+ if (event.type === 'step/end') {
164
+ pending.clear()
165
+ continue
166
+ }
167
+ if (event.type === 'tool/call') {
168
+ const callId = data.callId
169
+ if (typeof callId === 'string') pending.add(callId)
170
+ continue
171
+ }
172
+ if (event.type === 'tool/result') {
173
+ const source = data.message as { source?: { callId?: unknown } } | undefined
174
+ const callId = source?.source?.callId
175
+ if (typeof callId === 'string') pending.delete(callId)
176
+ }
177
+ }
178
+ return pending.size > 0
179
+ }
180
+
181
+ /** The plain text of one tool/result message (text blocks inside its
182
+ * `tool-result` content block). */
183
+ function toolResultText(data: Record<string, unknown>): string {
184
+ const message = data.message as { content?: unknown } | undefined
185
+ const content = message?.content
186
+ if (!Array.isArray(content)) return ''
187
+ const parts: string[] = []
188
+ for (const block of content) {
189
+ if (block === null || typeof block !== 'object') continue
190
+ const candidate = block as { type?: unknown; content?: unknown }
191
+ if (candidate.type !== 'tool-result') continue
192
+ const inner = candidate.content
193
+ if (!Array.isArray(inner)) continue
194
+ for (const item of inner) {
195
+ if (item === null || typeof item !== 'object') continue
196
+ const textItem = item as { type?: unknown; text?: unknown }
197
+ if (textItem.type === 'text' && typeof textItem.text === 'string') {
198
+ parts.push(textItem.text)
199
+ }
200
+ }
201
+ }
202
+ return parts.join('\n')
203
+ }
204
+
205
+ /** Cap applied to one tool-result's text inside a snapshot (prompt budget). */
206
+ const SNAPSHOT_RESULT_CAP = 2000
207
+ /** Cap applied to the whole snapshot (prompt budget). */
208
+ const SNAPSHOT_TOTAL_CAP = 8000
209
+
210
+ /**
211
+ * Build the side-thread inheritance for one parent log: the full event log
212
+ * up to the click moment, honestly closed when it ends inside an open turn.
213
+ */
214
+ export function buildSidechatInheritance(events: readonly SidechatLogEvent[]): SidechatInheritance {
215
+ if (events.length === 0) return { seed: [], snapshot: null }
216
+ const boundary = lastTurnBoundary(events)
217
+ if (boundary < 0 || events[boundary]?.type === 'turn/end') {
218
+ // Ends outside any turn (or has no turns at all): the whole log is a
219
+ // valid, balanced seed — possibly ending with a pending user message.
220
+ return { seed: copyEvents(events), snapshot: null }
221
+ }
222
+ // Ends inside the open turn starting at `boundary`.
223
+ if (hasDanglingToolCall(events, boundary)) {
224
+ // Cannot close honestly: cut before the open turn; the caller attaches
225
+ // the structured snapshot to the boundary prompt instead.
226
+ return {
227
+ seed: copyEvents(events.slice(0, boundary)),
228
+ snapshot: buildOpenTurnSnapshot(events),
229
+ }
230
+ }
231
+ const seed = copyEvents(events)
232
+ const last = events[events.length - 1]
233
+ const turn = numberAt(dataOf(events[boundary]!), 'turn')
234
+ const now = last?.time ?? 0
235
+ const openStep = openStepInTurn(events, boundary)
236
+ if (openStep !== undefined) {
237
+ seed.push({ type: 'step/end', seq: seed.length, time: now, data: { turn, step: openStep } })
238
+ }
239
+ seed.push({
240
+ type: 'turn/end',
241
+ seq: seed.length,
242
+ time: now,
243
+ data: { turn, reason: { kind: 'interrupted' } },
244
+ })
245
+ return { seed, snapshot: null }
246
+ }
247
+
248
+ /** The seed half of {@link buildSidechatInheritance} (test convenience). */
249
+ export function sidechatSeed(events: readonly SidechatLogEvent[]): SeedEvent[] {
250
+ return buildSidechatInheritance(events).seed
251
+ }
252
+
253
+ /**
254
+ * Structured text snapshot of the parent's OPEN turn (from its `turn/start`
255
+ * to the log tail): the accumulated assistant/reasoning output verbatim
256
+ * (code blocks ride the raw deltas) and the tool activity — executed tools
257
+ * with their result text, the still-executing one marked. Returns null when
258
+ * there is no open turn or nothing to show.
259
+ */
260
+ export function buildOpenTurnSnapshot(events: readonly SidechatLogEvent[]): string | null {
261
+ const boundary = lastTurnBoundary(events)
262
+ if (boundary < 0 || events[boundary]?.type !== 'turn/start') return null
263
+ let text = ''
264
+ let reasoning = ''
265
+ const tools: string[] = []
266
+ const pendingCalls = new Map<string, { name: string; args: string }>()
267
+ let total = 0
268
+ for (let index = boundary + 1; index < events.length; index++) {
269
+ const event = events[index]
270
+ if (event === undefined) continue
271
+ const data = dataOf(event)
272
+ if (event.type === 'step/end') {
273
+ pendingCalls.clear()
274
+ continue
275
+ }
276
+ if (event.type === 'assistant/chunk') {
277
+ const chunk = data.chunk as { type?: unknown; text?: unknown } | undefined
278
+ if (chunk === null || typeof chunk !== 'object') continue
279
+ if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
280
+ else if (chunk.type === 'reasoning-delta' && typeof chunk.text === 'string') reasoning += chunk.text
281
+ continue
282
+ }
283
+ if (event.type === 'tool/call') {
284
+ const callId = data.callId
285
+ if (typeof callId === 'string') {
286
+ pendingCalls.set(callId, {
287
+ name: typeof data.name === 'string' ? data.name : 'tool',
288
+ args: typeof data.arguments === 'string' ? data.arguments : '',
289
+ })
290
+ }
291
+ continue
292
+ }
293
+ if (event.type === 'tool/result') {
294
+ const source = data.message as { source?: { callId?: unknown } } | undefined
295
+ const callId = typeof source?.source?.callId === 'string' ? source.source.callId : undefined
296
+ const name = callId !== undefined ? pendingCalls.get(callId)?.name : undefined
297
+ const args = callId !== undefined ? pendingCalls.get(callId)?.args : undefined
298
+ if (callId !== undefined) pendingCalls.delete(callId)
299
+ const result = toolResultText(data).slice(0, SNAPSHOT_RESULT_CAP)
300
+ const failed = data.error !== undefined
301
+ const line = [
302
+ `- \`${name ?? 'tool'}\`${failed ? ' (failed)' : ''}`
303
+ + (args !== undefined && args !== '' ? ` — arguments: \`${args}\`` : ''),
304
+ ...(result === '' ? [] : [` Result: ${result}`]),
305
+ ].join('\n')
306
+ tools.push(line)
307
+ total += line.length
308
+ }
309
+ }
310
+ for (const [, call] of pendingCalls) {
311
+ const line = `- \`${call.name}\` (executing) — arguments: \`${call.args}\``
312
+ tools.push(line)
313
+ total += line.length
314
+ }
315
+ const sections: string[] = []
316
+ if (text.trim() !== '') sections.push(`Assistant output so far:\n\n${text}`)
317
+ if (reasoning.trim() !== '') sections.push(`Reasoning so far:\n\n${reasoning}`)
318
+ if (tools.length > 0) sections.push(`Tool activity:\n${tools.join('\n')}`)
319
+ if (sections.length === 0) return null
320
+ const body = sections.join('\n\n')
321
+ return body.length > SNAPSHOT_TOTAL_CAP
322
+ ? `Parent session in-progress turn (reference only):\n\n${body.slice(0, SNAPSHOT_TOTAL_CAP)}…`
323
+ : `Parent session in-progress turn (reference only):\n\n${body}`
324
+ }
325
+
326
+ /** One side-thread row in the client's thread list. */
327
+ export interface SideThreadRow {
328
+ id: string
329
+ /** The durable thread title ('Side: …'). */
330
+ title: string
331
+ /** Whether the thread's agent is currently running. */
332
+ running: boolean
333
+ }
334
+
335
+ /**
336
+ * Derive the side threads of one parent session from the client session list:
337
+ * durable `origin: 'subagent'` children of the parent whose pinned title
338
+ * carries the thread label prefix (our creation path pins it via
339
+ * sessionTitle.rename; dsh-sidechain threads share the convention, so they
340
+ * are visible here too).
341
+ */
342
+ export function sideThreadRows(
343
+ byId: Readonly<Record<string, SidebarSessionSummary>>,
344
+ sessionId: string,
345
+ ): SideThreadRow[] {
346
+ const rows: SideThreadRow[] = []
347
+ for (const summary of Object.values(byId)) {
348
+ if (summary.origin !== 'subagent' || summary.parentId !== sessionId) continue
349
+ if (!summary.displayTitle.startsWith(SIDE_LABEL_PREFIX)) continue
350
+ rows.push({ id: summary.id, title: summary.displayTitle, running: summary.running === true })
351
+ }
352
+ return rows
353
+ }
354
+
355
+ /** Truncate + prefix a question into a durable thread label. */
356
+ export function sideLabel(question: string): string {
357
+ const flat = question.replace(/\s+/g, ' ').trim()
358
+ const max = Math.max(1, LABEL_MAX_CHARS - SIDE_LABEL_PREFIX.length)
359
+ const body = flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
360
+ return `${SIDE_LABEL_PREFIX}${body}`
361
+ }
362
+
363
+ /**
364
+ * Whether the thread log already carries the side boundary message — i.e.
365
+ * the first prompt was delivered. Tolerant to the content shape (block
366
+ * array or bare string) and to inherited seed messages (only an OWN
367
+ * boundary message starts with the prefix; seed messages came from the
368
+ * parent's log, which never contains one).
369
+ */
370
+ export function boundaryDelivered(events: readonly SidechatLogEvent[]): boolean {
371
+ for (const event of events) {
372
+ if (event.type !== 'user/message') continue
373
+ if (messageLeadText(dataOf(event)).startsWith(SIDE_BOUNDARY_PREFIX)) return true
374
+ }
375
+ return false
376
+ }
377
+
378
+ /** The leading text of a user/message's content (block array or bare string). */
379
+ function messageLeadText(data: Record<string, unknown>): string {
380
+ const content = data.content
381
+ const first = Array.isArray(content) ? content[0] : content
382
+ return typeof first === 'string'
383
+ ? first
384
+ : (typeof first === 'object' && first !== null && 'text' in first
385
+ ? String((first as { text: unknown }).text)
386
+ : '')
387
+ }
388
+
389
+ /**
390
+ * Whether a logged user/message is a CONTEXT INJECTION (the boundary prompt
391
+ * plus the parked in-progress snapshot) rather than a real user message.
392
+ * New threads deliver the injection via `agent.inject` stamped with a
393
+ * non-'user' source kind; threads created before that split carry
394
+ * boundary+question in ONE 'user' message, recognized by the boundary
395
+ * prefix. Both render as one collapsible injection row — never as a user
396
+ * bubble.
397
+ */
398
+ export function isContextInjectionMessage(data: Record<string, unknown>): boolean {
399
+ const source = data.source as { kind?: unknown } | null | undefined
400
+ if (source?.kind !== undefined && source.kind !== 'user') return true
401
+ return messageLeadText(data).startsWith(SIDE_BOUNDARY_PREFIX)
402
+ }
403
+
404
+ /** The info the thread header shows (live runtime state + agent identity). */
405
+ export interface SidechatThreadInfo {
406
+ /** A live agent drives the thread right now (false = cold/persisted). */
407
+ live: boolean
408
+ /** Live lifecycle state; absent on cold threads. */
409
+ status?: 'idle' | 'running'
410
+ /** Provider route of the live agent. */
411
+ provider?: string
412
+ /** Model id of the live agent. */
413
+ model?: string
414
+ /** The recorded agent preset (live header, or persisted on cold reads). */
415
+ preset?: string
416
+ }
417
+
418
+ /** The events a thread produced itself: everything after the LAST
419
+ * `session/end-seed` marker (the fork-seed boundary). */
420
+ export function threadOwnEvents(entries: readonly SidebarHistoryEntry[]): SidechatLogEvent[] {
421
+ const events = entries.map(entry => entry.event)
422
+ for (let index = events.length - 1; index >= 0; index--) {
423
+ if (events[index]?.type === 'session/end-seed') return events.slice(index + 1)
424
+ }
425
+ return events
426
+ }
427
+
428
+ /**
429
+ * Whether the thread has at least one completed turn — the save-as-new-
430
+ * session precondition (`session.fork` refuses to fork before the first
431
+ * `turn/end`).
432
+ */
433
+ export function threadHasCompletedTurn(entries: readonly SidebarHistoryEntry[]): boolean {
434
+ return threadOwnEvents(entries).some(event => event.type === 'turn/end')
435
+ }
436
+
437
+ /** Whether the thread ends with a user message that no completed turn
438
+ * answered yet — such a pending follow-up is NOT carried into the saved
439
+ * session (the fork cut is the last `turn/end`). */
440
+ export function threadTrailingPending(entries: readonly SidebarHistoryEntry[]): boolean {
441
+ const own = threadOwnEvents(entries)
442
+ let lastUser = -1
443
+ let lastTurnEnd = -1
444
+ own.forEach((event, index) => {
445
+ if (event.type === 'user/message') lastUser = index
446
+ if (event.type === 'turn/end') lastTurnEnd = index
447
+ })
448
+ return lastUser > lastTurnEnd
449
+ }
450
+
451
+ /**
452
+ * The agent preset a session actually runs: newest `agent-preset/selected`
453
+ * event wins, else the creation header (mirror of the dsh-agent-presets
454
+ * resolveSessionPreset helper — replicated here to avoid a host dependency
455
+ * on that package).
456
+ */
457
+ export function resolvePresetId(
458
+ header: { agentPreset?: string },
459
+ events: readonly SidechatLogEvent[],
460
+ ): string | undefined {
461
+ for (let index = events.length - 1; index >= 0; index--) {
462
+ const event = events[index]
463
+ if (event?.type !== 'agent-preset/selected') continue
464
+ const preset = dataOf(event).agentPreset
465
+ if (typeof preset === 'string') return preset
466
+ }
467
+ return header.agentPreset
468
+ }