@cmodjs/core 1.0.0 → 1.5.0

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 (122) hide show
  1. package/docs/commands.md +22 -11
  2. package/docs/dependencies.md +6 -4
  3. package/docs/hooks.md +26 -3
  4. package/docs/index.md +1 -1
  5. package/docs/install-steps.md +5 -3
  6. package/docs/mod.md +29 -3
  7. package/docs/permissions.md +4 -4
  8. package/docs/session.md +126 -63
  9. package/docs/testing.md +7 -5
  10. package/docs/ui.md +9 -1
  11. package/jobs/program.d.ts.map +1 -1
  12. package/jobs/program.js +7 -2
  13. package/mod.d.ts +30 -6
  14. package/mod.d.ts.map +1 -1
  15. package/mod.js +34 -1
  16. package/package.json +1 -1
  17. package/records.d.ts +4 -1
  18. package/records.d.ts.map +1 -1
  19. package/records.js +6 -1
  20. package/register.d.ts.map +1 -1
  21. package/register.js +17 -0
  22. package/runtime/announce.d.ts.map +1 -1
  23. package/runtime/announce.js +2 -0
  24. package/runtime/before.d.ts +32 -0
  25. package/runtime/before.d.ts.map +1 -0
  26. package/runtime/before.js +214 -0
  27. package/runtime/claude.d.ts +2 -1
  28. package/runtime/claude.d.ts.map +1 -1
  29. package/runtime/conversations.d.ts +1 -0
  30. package/runtime/conversations.d.ts.map +1 -1
  31. package/runtime/conversations.js +333 -61
  32. package/runtime/dependencies.js +1 -1
  33. package/runtime/engine.d.ts +2 -6
  34. package/runtime/engine.d.ts.map +1 -1
  35. package/runtime/grants.d.ts.map +1 -1
  36. package/runtime/grants.js +8 -8
  37. package/runtime/hooks.d.ts +1 -1
  38. package/runtime/hooks.d.ts.map +1 -1
  39. package/runtime/hooks.js +19 -4
  40. package/runtime/installer.d.ts +1 -1
  41. package/runtime/installer.d.ts.map +1 -1
  42. package/runtime/installer.js +6 -5
  43. package/runtime/lifecycle.d.ts.map +1 -1
  44. package/runtime/lifecycle.js +129 -58
  45. package/runtime/metadata.d.ts.map +1 -1
  46. package/runtime/metadata.js +5 -4
  47. package/runtime/sessions.d.ts +26 -4
  48. package/runtime/sessions.d.ts.map +1 -1
  49. package/runtime/sessions.js +315 -117
  50. package/runtime/toaster.d.ts +5 -12
  51. package/runtime/toaster.d.ts.map +1 -1
  52. package/runtime/toaster.js +133 -23
  53. package/runtime/ui.d.ts.map +1 -1
  54. package/runtime/ui.js +26 -6
  55. package/session/context/context.d.ts +3 -1
  56. package/session/context/context.d.ts.map +1 -1
  57. package/session/context/turn/message/message.d.ts +1 -0
  58. package/session/context/turn/message/message.d.ts.map +1 -1
  59. package/session/context/turn/message/message.js +42 -1
  60. package/session/context/turn/turn.d.ts +2 -1
  61. package/session/context/turn/turn.d.ts.map +1 -1
  62. package/session/conversation.d.ts +20 -11
  63. package/session/conversation.d.ts.map +1 -1
  64. package/session/conversation.js +50 -27
  65. package/session/goal/goal.d.ts +3 -2
  66. package/session/goal/goal.d.ts.map +1 -1
  67. package/session/goal/task.d.ts +4 -4
  68. package/session/goal/task.d.ts.map +1 -1
  69. package/session/session.d.ts +80 -21
  70. package/session/session.d.ts.map +1 -1
  71. package/session/session.js +1 -0
  72. package/src/jobs/program.ts +7 -2
  73. package/src/mod.ts +48 -5
  74. package/src/records.ts +6 -1
  75. package/src/register.ts +17 -0
  76. package/src/runtime/announce.ts +2 -0
  77. package/src/runtime/before.ts +200 -0
  78. package/src/runtime/claude.ts +3 -0
  79. package/src/runtime/conversations.ts +337 -60
  80. package/src/runtime/dependencies.ts +1 -1
  81. package/src/runtime/engine.ts +2 -1
  82. package/src/runtime/grants.ts +8 -10
  83. package/src/runtime/hooks.ts +20 -4
  84. package/src/runtime/installer.ts +6 -5
  85. package/src/runtime/lifecycle.ts +135 -55
  86. package/src/runtime/metadata.ts +5 -4
  87. package/src/runtime/sessions.ts +316 -111
  88. package/src/runtime/toaster.ts +160 -44
  89. package/src/runtime/ui.ts +23 -4
  90. package/src/session/context/context.ts +3 -1
  91. package/src/session/context/turn/message/message.ts +39 -1
  92. package/src/session/context/turn/turn.ts +2 -1
  93. package/src/session/conversation.ts +56 -31
  94. package/src/session/goal/goal.ts +3 -2
  95. package/src/session/goal/task.ts +4 -4
  96. package/src/session/session.ts +62 -15
  97. package/src/testing/fake-claude.ts +4 -2
  98. package/src/testing/fake-sessions.ts +94 -20
  99. package/src/testing.ts +27 -3
  100. package/src/utils/call-effects.ts +2 -2
  101. package/src/utils/disposable.ts +3 -0
  102. package/src/utils/matcher.ts +1 -1
  103. package/src/utils/paths.ts +6 -3
  104. package/testing/fake-claude.d.ts +1 -0
  105. package/testing/fake-claude.d.ts.map +1 -1
  106. package/testing/fake-claude.js +3 -1
  107. package/testing/fake-sessions.d.ts +8 -1
  108. package/testing/fake-sessions.d.ts.map +1 -1
  109. package/testing/fake-sessions.js +97 -18
  110. package/testing.d.ts +1 -0
  111. package/testing.d.ts.map +1 -1
  112. package/testing.js +23 -2
  113. package/utils/call-effects.d.ts +1 -0
  114. package/utils/call-effects.d.ts.map +1 -1
  115. package/utils/call-effects.js +1 -1
  116. package/utils/disposable.d.ts +4 -0
  117. package/utils/disposable.d.ts.map +1 -0
  118. package/utils/disposable.js +1 -0
  119. package/utils/matcher.js +1 -1
  120. package/utils/paths.d.ts +2 -1
  121. package/utils/paths.d.ts.map +1 -1
  122. package/utils/paths.js +6 -3
@@ -1,30 +1,46 @@
1
1
  import type { ElementTable, RenderElement, RenderPropsOf, Timer } from 'claude-code'
2
- import type { Mod, Toast, ToastId, ToastOptions, ToastType } from '../mod.js'
3
- import { Box, Button, drawWith, Text } from '../ui/elements.js'
4
- import { coreVersion } from '../records.js'
2
+ import type { Toast, ToastId, ToastOptions, ToastType } from '../mod.js'
3
+ import { Box, Button, drawWith, Link, Text } from '../ui/elements.js'
4
+ import { coreVersion, isObject } from '../records.js'
5
5
  import { firstLine, messageOf } from '../utils/text.js'
6
6
  import type { Claude } from './claude.js'
7
7
 
8
8
  export type ShownToast = { readonly type: ToastType; readonly message: string; readonly description?: string }
9
9
 
10
- export type Failure = { readonly id: string; readonly message: string; readonly error?: unknown; readonly description?: string }
10
+ export type Failure = { readonly id: string; readonly message: string; readonly error?: unknown; readonly description?: string; readonly retry?: () => unknown }
11
11
 
12
12
  export type Toaster = {
13
13
  readonly toast: Toast
14
14
  copy(text: string): Promise<void>
15
+ fail(failure: Failure): ToastId
15
16
  readonly shown: readonly ShownToast[]
16
- draw(drawn: RenderElement, table: ElementTable, band: RenderPropsOf['AbovePrompt']): RenderElement
17
+ draw(drawn: RenderElement, table: ElementTable, band: RenderPropsOf['AbovePrompt'], bandId: string): RenderElement
17
18
  }
18
19
 
20
+ type Action = { readonly label: string; readonly onClick: () => unknown }
21
+
22
+ type Contact = { readonly kind: 'github'; readonly repository: string } | { readonly kind: 'email'; readonly address: string }
23
+
24
+ type Reporting = { readonly contact: Contact | undefined; readonly details: string; readonly status?: string }
25
+
19
26
  type Entry = {
20
27
  readonly id: ToastId
21
28
  readonly type: ToastType
22
29
  readonly message: string
23
30
  readonly options: ToastOptions
31
+ readonly actions: readonly Action[]
32
+ readonly hides?: string
24
33
  isOpen: boolean
34
+ reporting?: Reporting | undefined
25
35
  timer: Timer | undefined
26
36
  }
27
37
 
38
+ const githubRepository = /^https:\/\/github\.com\/([^/]+\/[^/#?]+?)(?:\.git)?(?:\/issues)?\/?$/
39
+
40
+ const linkLimit = 2048
41
+
42
+ const hiddenKey = 'cmod:hidden-failures'
43
+
28
44
  const defaultDuration = 4000
29
45
  const visibleToasts = 3
30
46
  const spinner = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const
@@ -37,13 +53,22 @@ const icons: Record<Exclude<ToastType, 'loading'>, { readonly glyph: string; rea
37
53
  error: { glyph: '✗', color: 'error' },
38
54
  }
39
55
 
40
- export function createToaster(claude: Claude, name: string): Toaster {
56
+ export function createToaster(claude: Claude, name: string, version: () => string | undefined): Toaster {
41
57
  const entries = new Map<ToastId, Entry>()
58
+ const hidden = new Map<string, string>()
59
+ void claude.store.get(hiddenKey).then(
60
+ (saved) => {
61
+ if (typeof saved !== 'object' || saved === null) return
62
+ for (const [id, hides] of Object.entries(saved)) if (typeof hides === 'string' && !hidden.has(id)) hidden.set(id, hides)
63
+ },
64
+ (error: unknown) => claude.ui.log(`${name}: could not read the failures you hid: ${messageOf(error)}`, { to: 'debug' }),
65
+ )
42
66
  let lastId = 0
43
67
  let frame = 0
44
68
  let spinning: Timer | undefined
45
69
  let isHeld = false
46
70
  let showsAll = false
71
+ let bandId: string | undefined
47
72
 
48
73
  const changed = () => {
49
74
  const isLoading = [...entries.values()].some((entry) => entry.type === 'loading')
@@ -85,18 +110,29 @@ export function createToaster(claude: Claude, name: string): Toaster {
85
110
  changed()
86
111
  }
87
112
 
113
+ const put = (type: ToastType, message: string, options: ToastOptions, actions: readonly Action[], hides?: string): ToastId => {
114
+ const id = options.id ?? (lastId += 1)
115
+ const previous = entries.get(id)
116
+ if (previous !== undefined) disarm(previous)
117
+ const reporting = hides !== undefined && previous?.hides === hides ? previous.reporting : undefined
118
+ const entry: Entry = { id, type, message, options, actions, ...(hides === undefined ? {} : { hides }), isOpen: previous?.isOpen ?? false, reporting, timer: undefined }
119
+ if (type === 'error' && previous?.message !== message) claude.ui.log(`${name}: ${message}${typeof options.description === 'string' ? `: ${options.description}` : ''}`, { to: 'debug' })
120
+ entries.set(id, entry)
121
+ changed()
122
+ return id
123
+ }
124
+
88
125
  const show =
89
126
  (type: ToastType) =>
90
- (message: string, options: ToastOptions = {}): ToastId => {
91
- const id = options.id ?? (lastId += 1)
92
- const previous = entries.get(id)
93
- if (previous !== undefined) disarm(previous)
94
- const entry: Entry = { id, type, message, options, isOpen: previous?.isOpen ?? false, timer: undefined }
95
- if (type === 'error' && previous?.message !== message) claude.ui.log(`${name}: ${message}${typeof options.description === 'string' ? `: ${options.description}` : ''}`, { to: 'debug' })
96
- entries.set(id, entry)
97
- changed()
98
- return id
99
- }
127
+ (message: string, options: ToastOptions = {}): ToastId =>
128
+ put(type, message, options, options.action === undefined ? [] : [options.action])
129
+
130
+ const hide = (entry: Entry) => {
131
+ dismiss(entry.id)
132
+ if (entry.hides === undefined) return
133
+ hidden.set(String(entry.id), entry.hides)
134
+ void claude.store.set(hiddenKey, Object.fromEntries(hidden)).catch((error: unknown) => claude.ui.log(`${name}: could not remember the failure you hid: ${messageOf(error)}`, { to: 'debug' }))
135
+ }
100
136
 
101
137
  const toast: Toast = Object.assign(show('default'), {
102
138
  success: show('success'),
@@ -132,7 +168,33 @@ export function createToaster(claude: Claude, name: string): Toaster {
132
168
  const press = (entry: Entry, run: () => unknown) => () =>
133
169
  void Promise.resolve()
134
170
  .then(run)
135
- .catch((error: unknown) => showFailure({ name, claude, ui: { toast, copy } }, { id: `press:${entry.id}`, message: `${entry.message}: the press failed`, error }))
171
+ .catch((error: unknown) => fail({ id: `press:${entry.id}`, message: `${entry.message}: the press failed`, error }))
172
+
173
+ const fail = ({ id, message, error, description = error === undefined ? undefined : describe(error), retry }: Failure): ToastId => {
174
+ const hides = `${version() ?? ''}\n${message}\n${error === undefined ? '' : describe(error)}`
175
+ if (hidden.get(id) === hides) return id
176
+ hidden.delete(id)
177
+ const stack = error instanceof Error && error.stack !== undefined ? `\n\n${error.stack}` : ''
178
+ const details = async () => {
179
+ const modVersion = await claude.fs
180
+ .read(`${claude.plugin.root}/.claude-plugin/plugin.json`)
181
+ .then((text) => String((JSON.parse(text) as { version?: unknown }).version))
182
+ .catch((failure: unknown) => `unknown (${messageOf(failure)})`)
183
+ return `${name} ${modVersion}, @cmodjs/core ${coreVersion}\n${message}${description === undefined ? '' : `\n${description}`}${stack}`
184
+ }
185
+ const startReporting = async () => {
186
+ const shown = entries.get(id)
187
+ if (shown === undefined) return
188
+ shown.reporting = { contact: await contactOf(claude), details: await details() }
189
+ disarm(shown)
190
+ changed()
191
+ if (bandId === undefined) return
192
+ const focused = await claude.ui.focus({ requestId: bandId, key: `toast:${id}:ask-claude` })
193
+ if ('deny' in focused) claude.ui.log(`${name}: the Report choices did not take the keys: ${String(focused.deny)}`, { to: 'debug' })
194
+ }
195
+ const actions = [...(retry === undefined ? [] : [{ label: 'Retry', onClick: retry }]), { label: 'Report', onClick: startReporting }]
196
+ return put('error', message, { id, ...(description === undefined ? {} : { description }), duration: Infinity }, actions, hides)
197
+ }
136
198
 
137
199
  const toggle = (entry: Entry) => () => {
138
200
  entry.isOpen = !entry.isOpen
@@ -141,31 +203,70 @@ export function createToaster(claude: Claude, name: string): Toaster {
141
203
  }
142
204
 
143
205
  const row = (entry: Entry): RenderElement => {
144
- const { description, action } = entry.options
206
+ const { description } = entry.options
145
207
  const isText = typeof description === 'string'
146
208
  const icon = entry.type === 'loading' ? { glyph: spinner[frame % spinner.length] as string, color: 'claude' } : icons[entry.type]
147
209
  const message = isText ? Button({ plain: true, key: `toast:${entry.id}:open`, label: entry.message, onPress: toggle(entry) }) : Text({ children: entry.message })
148
210
  const details = !isText ? description : Text({ dimColor: true, wrap: entry.isOpen ? 'wrap' : 'truncate-end', children: entry.isOpen ? description : firstLine(description) })
149
211
  const fixed = (child: RenderElement) => Box({ flexShrink: 0, children: child })
212
+ const close = Button({ plain: true, dimColor: true, key: `toast:${entry.id}:close`, label: entry.hides === undefined ? '✕' : 'Hide', onPress: () => hide(entry) })
213
+ const actions = entry.actions.map((action, index) => Button({ plain: true, key: `toast:${entry.id}:${index === 0 ? 'action' : `action-${index}`}`, label: action.label, onPress: press(entry, action.onClick) }))
214
+ const text = details === undefined ? [message] : [message, details]
215
+ const under = entry.reporting === undefined ? [Box({ gap: 2, flexWrap: 'wrap', children: [...actions, close] })] : reportChoices(entry, entry.reporting)
150
216
  return Box({
151
217
  gap: 2,
152
218
  children: [
153
219
  fixed(Text({ ...(icon.color === undefined ? {} : { color: icon.color }), children: icon.glyph })),
154
220
  fixed(Text({ dimColor: true, children: name })),
155
- Box({ flexDirection: 'column', flexGrow: 1, flexShrink: 1, children: details === undefined ? [message] : [message, details] }),
156
- ...(action === undefined ? [] : [fixed(Button({ plain: true, key: `toast:${entry.id}:action`, label: action.label, onPress: press(entry, action.onClick) }))]),
157
- fixed(Button({ plain: true, dimColor: true, key: `toast:${entry.id}:close`, label: '✕', onPress: () => dismiss(entry.id) })),
221
+ Box({ flexDirection: 'column', flexGrow: 1, flexShrink: 1, children: entry.hides === undefined ? text : [...text, ...under] }),
222
+ ...(entry.hides === undefined ? [...actions.map(fixed), fixed(close)] : []),
158
223
  ],
159
224
  })
160
225
  }
161
226
 
227
+ const reportChoices = (entry: Entry, { contact, details, status }: Reporting): RenderElement[] => {
228
+ const say = (said: string) => {
229
+ entry.reporting = { contact, details, status: said }
230
+ changed()
231
+ }
232
+ const back = () => {
233
+ entry.reporting = undefined
234
+ changed()
235
+ }
236
+ const askClaude = async () => {
237
+ await claude.prompt.submit({ text: reportPrompt(name, contact, details), asUser: true })
238
+ back()
239
+ }
240
+ const copyError = async () => {
241
+ const copied = await claude.ui.copy({ text: details })
242
+ say(copied.isCopied ? 'Copied the error.' : `Could not copy it: ${copied.reason}`)
243
+ }
244
+ const report = contact === undefined ? [] : [Link({ href: reportLink(contact, name, entry.message, details), label: contact.kind === 'github' ? 'Report it on GitHub (opens your browser)' : 'Email the author (opens your mail app)' })]
245
+ return [
246
+ Text({ dimColor: true, children: 'Claude writes up the problem and files it with your tools. You approve each step before anything is posted.' }),
247
+ Box({
248
+ gap: 2,
249
+ flexWrap: 'wrap',
250
+ children: [
251
+ Button({ plain: true, key: `toast:${entry.id}:ask-claude`, label: 'Ask Claude to report it', onPress: press(entry, askClaude) }),
252
+ ...report,
253
+ Button({ plain: true, key: `toast:${entry.id}:copy`, label: 'Copy the error', onPress: press(entry, copyError) }),
254
+ Button({ plain: true, dimColor: true, key: `toast:${entry.id}:back`, label: 'Back', onPress: back }),
255
+ ],
256
+ }),
257
+ ...(status === undefined ? [] : [Text({ dimColor: true, children: status })]),
258
+ ]
259
+ }
260
+
162
261
  return {
163
262
  toast,
164
263
  copy,
264
+ fail,
165
265
  get shown() {
166
266
  return [...entries.values()].map(({ type, message, options }) => ({ type, message, ...(typeof options.description === 'string' ? { description: options.description } : {}) }))
167
267
  },
168
- draw(drawn, table, band) {
268
+ draw(drawn, table, band, drawnIn) {
269
+ bandId = drawnIn
169
270
  isHeld = band.hasSurvey === true
170
271
  if (isHeld || entries.size === 0) return drawn
171
272
  const all = [...entries.values()]
@@ -186,28 +287,6 @@ export function createToaster(claude: Claude, name: string): Toaster {
186
287
  }
187
288
  }
188
289
 
189
- export type FailureTarget = {
190
- readonly name: string
191
- readonly ui: Pick<Mod['ui'], 'toast' | 'copy'>
192
- readonly claude: { readonly plugin: Claude['plugin']; readonly fs: Pick<Claude['fs'], 'read'> }
193
- }
194
-
195
- export function showFailure({ name, ui, claude }: FailureTarget, { id, message, error, description = describe(error) }: Failure): ToastId {
196
- const stack = error instanceof Error && error.stack !== undefined ? `\n\n${error.stack}` : ''
197
- const show = (label: string): ToastId => ui.toast.error(message, { id, description, duration: Infinity, action: { label, onClick: copy } })
198
- const copy = async (): Promise<ToastId> => {
199
- const modVersion = await claude.fs
200
- .read(`${claude.plugin.root}/.claude-plugin/plugin.json`)
201
- .then((text) => String((JSON.parse(text) as { version?: unknown }).version))
202
- .catch((failure: unknown) => `unknown (${messageOf(failure)})`)
203
- return ui.copy(`${name} ${modVersion}, @cmodjs/core ${coreVersion}\n${message}\n${description}${stack}`).then(
204
- () => show('Copied'),
205
- (failure: unknown) => show(`Copy failed: ${messageOf(failure)}`),
206
- )
207
- }
208
- return show('Copy')
209
- }
210
-
211
290
  function describe(error: unknown): string {
212
291
  const messages: string[] = []
213
292
  for (let cause = error; cause !== undefined; cause = cause instanceof Error ? cause.cause : undefined) {
@@ -216,3 +295,40 @@ function describe(error: unknown): string {
216
295
  }
217
296
  return messages.join('\n')
218
297
  }
298
+
299
+ async function contactOf(claude: Claude): Promise<Contact | undefined> {
300
+ const manifest: Record<string, unknown> = await claude.fs
301
+ .read(`${claude.plugin.root}/.claude-plugin/plugin.json`)
302
+ .then((text) => JSON.parse(text) as Record<string, unknown>)
303
+ .catch((error: unknown) => {
304
+ claude.ui.log(`${claude.plugin.name}: could not read whom to report to: ${messageOf(error)}`, { to: 'debug' })
305
+ return {}
306
+ })
307
+ const repositoryUrl = isObject(manifest['repository']) ? manifest['repository']['url'] : manifest['repository']
308
+ const repository = [manifest['supportUrl'], repositoryUrl].map((url) => (typeof url === 'string' ? githubRepository.exec(url)?.[1] : undefined)).find((found) => found !== undefined)
309
+ if (repository !== undefined) return { kind: 'github', repository }
310
+ const author = manifest['author']
311
+ const address = isObject(author) ? author['email'] : undefined
312
+ return typeof address === 'string' && address !== '' ? { kind: 'email', address } : undefined
313
+ }
314
+
315
+ function reportLink(contact: Contact, name: string, message: string, details: string): string {
316
+ const title = `${name}: ${message}`.slice(0, 120)
317
+ const linkOf = (body: string) =>
318
+ contact.kind === 'github'
319
+ ? `https://github.com/${contact.repository}/issues/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`
320
+ : `mailto:${contact.address}?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`
321
+ let body = details
322
+ while (linkOf(body).length > linkLimit && body.length > 0) body = `${body.slice(0, Math.floor(body.length * 0.8))}…`
323
+ return linkOf(body)
324
+ }
325
+
326
+ function reportPrompt(name: string, contact: Contact | undefined, details: string): string {
327
+ const where =
328
+ contact === undefined
329
+ ? `${name} names no place to report it, so find its author from its plugin.json or its marketplace.`
330
+ : contact.kind === 'github'
331
+ ? `Report it as an issue on https://github.com/${contact.repository}.`
332
+ : `Email it to ${contact.address}.`
333
+ return `The ${name} mod failed. Report it to its author. ${where} First check whether it is reported already, and add to that report instead of filing a new one. Show me the report before you post it.\n\n${details}`
334
+ }
package/src/runtime/ui.ts CHANGED
@@ -107,6 +107,15 @@ export function createUi<State extends object>({ name, claude, router, toaster,
107
107
  const panes = new Map<string, Pane<State>>()
108
108
  const paneLogs = new Map<string, (failure: string) => void>()
109
109
  const openPanes = new Set<string>()
110
+ const paneStates = new Map<string, { readonly isShown: boolean; readonly isFocused: boolean }>()
111
+ const syncPanes = async () => {
112
+ for (const open of await claude.ui.panes()) {
113
+ if (!panes.has(open.id)) continue
114
+ if (open.isPlaced) openPanes.add(open.id)
115
+ paneStates.set(open.id, { isShown: open.isShown, isFocused: open.isFocused })
116
+ }
117
+ }
118
+ const readPanes = () => void syncPanes().catch((error: unknown) => claude.ui.log(`${name} keeps its panes' shown and focused state from before: ${messageOf(error)}`, { to: 'debug' }))
110
119
  const requestedSizes = new Map<string, PaneSize>()
111
120
  const renders = new Set<string>()
112
121
  const markdownRenders = new Map<string, MarkdownRender>()
@@ -197,6 +206,8 @@ export function createUi<State extends object>({ name, claude, router, toaster,
197
206
  const pane = panes.get(e.requestId)
198
207
  if (pane === undefined || e.component !== 'Pane') return next(e)
199
208
  openPanes.add(pane.id)
209
+ paneStates.set(pane.id, { isShown: true, isFocused: e.props.isFocused })
210
+ readPanes()
200
211
  const table = claude.ui.resolve(e)
201
212
  try {
202
213
  return drawWith(table, () => pane.render(mod(), e.props), markdownIn(table))
@@ -214,6 +225,8 @@ export function createUi<State extends object>({ name, claude, router, toaster,
214
225
  })
215
226
  router.add('ui.close', async (e, next) => {
216
227
  openPanes.delete(e.id)
228
+ paneStates.delete(e.id)
229
+ readPanes()
217
230
  const closed = await next(e)
218
231
  const pane = panes.get(e.id)
219
232
  if (pane?.onClose !== undefined) await handle(pane, 'onClose', () => pane.onClose?.(mod(), e))
@@ -241,13 +254,22 @@ export function createUi<State extends object>({ name, claude, router, toaster,
241
254
  get isOpen() {
242
255
  return openPanes.has(pane.id)
243
256
  },
257
+ get isShown() {
258
+ return paneStates.get(pane.id)?.isShown ?? false
259
+ },
260
+ get isFocused() {
261
+ return paneStates.get(pane.id)?.isFocused ?? false
262
+ },
244
263
  async open(options) {
245
264
  const { isPlaced } = await openAt(pane, paneSize(pane, mod().state), options?.focus)
246
265
  if (isPlaced) openPanes.add(pane.id)
266
+ await syncPanes()
247
267
  },
248
268
  async close() {
249
269
  await claude.ui.close({ id: pane.id })
250
270
  openPanes.delete(pane.id)
271
+ paneStates.delete(pane.id)
272
+ await syncPanes()
251
273
  },
252
274
  toggle: (options) => (openPanes.has(pane.id) ? handle.close() : handle.open(options)),
253
275
  }
@@ -316,10 +338,7 @@ export function createUi<State extends object>({ name, claude, router, toaster,
316
338
  })
317
339
  },
318
340
  async restorePanes() {
319
- if (panes.size === 0) return
320
- for (const open of await claude.ui.panes()) {
321
- if (open.isPlaced && panes.has(open.id)) openPanes.add(open.id)
322
- }
341
+ if (panes.size > 0) await syncPanes()
323
342
  },
324
343
  }
325
344
  }
@@ -1,12 +1,14 @@
1
+ import type { RecordPart } from '../session.js'
1
2
  import type { Turn } from './turn/turn.js'
2
3
 
3
4
  export const ContextStart = 'contextStart'
4
5
 
5
6
  export const ContextEnd = 'contextEnd'
6
7
 
7
- export type Context = {
8
+ export type Context = RecordPart & {
8
9
  readonly number: number
9
10
  readonly openedAt: string
10
11
  readonly closedAt?: string
12
+ readonly task?: string
11
13
  turns(): Promise<Turn[]>
12
14
  }
@@ -112,7 +112,7 @@ function unplacedOf(row: Row, isInTask: boolean): Unplaced[] {
112
112
  })
113
113
  }
114
114
  if (row.isSummary) return []
115
- const results = row.content.filter((block) => isObject(block) && block['type'] === 'tool_result' && typeof block['tool_use_id'] === 'string') as Readonly<Record<string, unknown>>[]
115
+ const results = row.content.filter(isToolResultBlock)
116
116
  if (results.length > 0) return results.map((block) => ({ ...base, type: ToolResult, callId: block['tool_use_id'] as string, text: textOf(block['content']), isError: block['is_error'] === true }))
117
117
  if (text === '') return []
118
118
  if (text.startsWith(skillPrefix)) return [{ ...base, type: Skill, name: basename(text.slice(skillPrefix.length).split('\n')[0]?.trim() ?? ''), text }]
@@ -122,6 +122,44 @@ function unplacedOf(row: Row, isInTask: boolean): Unplaced[] {
122
122
  return prompt === undefined ? [] : [{ ...base, type: Prompt, text: prompt, from: senderOf(row.origin, isInTask) }]
123
123
  }
124
124
 
125
+ export function withTexts(row: Row, texts: ReadonlyMap<number, string>): readonly unknown[] {
126
+ if (row.type === 'assistant') {
127
+ let index = -1
128
+ return row.content.map((block) => {
129
+ if (!isObject(block)) return block
130
+ const isText = block['type'] === 'text' && typeof block['text'] === 'string' && block['text'].trim() !== ''
131
+ const isCall = block['type'] === 'tool_use' && typeof block['id'] === 'string' && typeof block['name'] === 'string'
132
+ if (!isText && !isCall) return block
133
+ index += 1
134
+ const text = texts.get(index)
135
+ return isText && text !== undefined ? { ...block, text } : block
136
+ })
137
+ }
138
+ if (row.type === 'user' && row.content.some(isToolResultBlock)) {
139
+ let index = -1
140
+ return row.content.map((block) => {
141
+ if (!isToolResultBlock(block)) return block
142
+ index += 1
143
+ const text = texts.get(index)
144
+ if (text === undefined) return block
145
+ return { ...block, content: Array.isArray(block['content']) ? keepingMedia(block['content'], text) : text }
146
+ })
147
+ }
148
+ const text = texts.get(0)
149
+ if (text === undefined) return row.content
150
+ const whole = textOf(row.content)
151
+ if (whole.trim().startsWith('<task-notification') && tagOf(whole, 'summary') !== undefined) return keepingMedia(row.content, whole.replace(/<summary>[\s\S]*?<\/summary>/, () => `<summary>${text}</summary>`))
152
+ return keepingMedia(row.content, text)
153
+ }
154
+
155
+ function keepingMedia(content: readonly unknown[], text: string): unknown[] {
156
+ return [{ type: 'text', text }, ...content.filter((block) => !isObject(block) || block['type'] !== 'text')]
157
+ }
158
+
159
+ function isToolResultBlock(block: unknown): block is Readonly<Record<string, unknown>> {
160
+ return isObject(block) && block['type'] === 'tool_result' && typeof block['tool_use_id'] === 'string'
161
+ }
162
+
125
163
  export function textOf(content: unknown): string {
126
164
  if (typeof content === 'string') return content
127
165
  if (!Array.isArray(content)) return ''
@@ -1,3 +1,4 @@
1
+ import type { RecordPart } from '../../session.js'
1
2
  import type { Message } from './message/message.js'
2
3
  import type { Prompt } from './message/prompt.js'
3
4
 
@@ -5,7 +6,7 @@ export const TurnStart = 'turnStart'
5
6
 
6
7
  export const TurnEnd = 'turnEnd'
7
8
 
8
- export type Turn = {
9
+ export type Turn = RecordPart & {
9
10
  readonly prompt: Prompt
10
11
  readonly context: number
11
12
  readonly openedAt: string
@@ -7,6 +7,7 @@ import { ToolResult } from './context/turn/message/tool-result.js'
7
7
  import { TurnEnd, TurnStart, type Turn } from './context/turn/turn.js'
8
8
  import { GoalChange, type Goal } from './goal/goal.js'
9
9
  import { Task, TaskEnd, TaskStart } from './goal/task.js'
10
+ import type { UsageChange, Usage } from './session.js'
10
11
 
11
12
  export type SessionData = {
12
13
  readonly id: string
@@ -18,22 +19,26 @@ export type SessionData = {
18
19
  readonly lastAt: string
19
20
  readonly forkedFrom?: string
20
21
  readonly isRunning: boolean
22
+ readonly agentType?: string
21
23
  }
22
24
 
23
- export type ContextData = Omit<Context, 'turns'>
25
+ export type ContextData = Pick<Context, 'number' | 'openedAt' | 'closedAt' | 'task'>
24
26
 
25
- export type TurnData = Omit<Turn, 'messages'>
27
+ export type TurnData = Pick<Turn, 'prompt' | 'context' | 'openedAt' | 'closedAt'>
26
28
 
27
- export type TaskData = Omit<Task, 'messages' | 'tasks'> & { readonly parent?: string }
29
+ export type TaskData = Pick<Task, 'id' | 'title' | 'openedBy' | 'openedAt' | 'closedAt'> & { readonly agentType: string; readonly model?: string; readonly parent?: string }
28
30
 
29
- export type GoalData = Omit<Goal, 'update' | 'tasks'>
31
+ export type RunningTask = TaskData & { readonly context: number; readonly contextOpenedAt?: string }
32
+
33
+ export type GoalData = Pick<Goal, 'text'> & { readonly task?: string }
30
34
 
31
35
  export type SessionChange =
32
36
  | { readonly type: typeof Message; readonly message: Message }
33
37
  | { readonly type: typeof TurnStart | typeof TurnEnd; readonly turn: TurnData }
34
38
  | { readonly type: typeof ContextStart | typeof ContextEnd; readonly context: ContextData }
35
- | { readonly type: typeof TaskStart | typeof TaskEnd; readonly task: TaskData & { readonly context: number } }
39
+ | { readonly type: typeof TaskStart | typeof TaskEnd; readonly task: RunningTask }
36
40
  | { readonly type: typeof GoalChange; readonly goal: GoalData }
41
+ | { readonly type: typeof UsageChange; readonly usage: Usage }
37
42
 
38
43
  export type ConversationState = {
39
44
  readonly session: string
@@ -43,13 +48,13 @@ export type ConversationState = {
43
48
  readonly goal?: string
44
49
  readonly agentCalls: readonly string[]
45
50
  readonly endedCalls: Readonly<Record<string, string>>
46
- readonly tasks: Readonly<Record<string, TaskData & { readonly context: number }>>
51
+ readonly tasks: Readonly<Record<string, RunningTask>>
52
+ readonly taskTurns: Readonly<Record<string, TurnData>>
47
53
  }
48
54
 
49
55
  export type Conversation = {
50
56
  add(row: Row, task?: string): SessionChange[]
51
57
  startTask(task: TaskData): SessionChange[]
52
- endContext(at: string): SessionChange[]
53
58
  startContext(at: string): SessionChange[]
54
59
  changeGoal(text: string): SessionChange[]
55
60
  readonly state: ConversationState
@@ -62,7 +67,7 @@ const agentTools: readonly string[] = ['Agent', 'Task']
62
67
  const asyncLaunch = 'Async agent launched'
63
68
 
64
69
  export function emptyConversation(session: string): ConversationState {
65
- return { session, context: 0, contextOpenedAt: '', agentCalls: [], endedCalls: {}, tasks: {} }
70
+ return { session, context: 0, contextOpenedAt: '', agentCalls: [], endedCalls: {}, tasks: {}, taskTurns: {} }
66
71
  }
67
72
 
68
73
  export function createConversation(start: ConversationState): Conversation {
@@ -70,6 +75,14 @@ export function createConversation(start: ConversationState): Conversation {
70
75
  const agentCalls = new Set(start.agentCalls)
71
76
  const endedCalls = new Map(Object.entries(start.endedCalls))
72
77
  const tasks = new Map(Object.entries(start.tasks))
78
+ const taskTurns = new Map(Object.entries(start.taskTurns))
79
+
80
+ const endTaskTurn = (task: string, at: string): SessionChange[] => {
81
+ const open = taskTurns.get(task)
82
+ if (open === undefined) return []
83
+ taskTurns.delete(task)
84
+ return open.closedAt === undefined ? [{ type: TurnEnd, turn: { ...open, closedAt: at } }] : []
85
+ }
73
86
 
74
87
  const startContext = (at: string): SessionChange[] => {
75
88
  context += 1
@@ -79,6 +92,17 @@ export function createConversation(start: ConversationState): Conversation {
79
92
 
80
93
  const endContext = (at: string): SessionChange[] => (context === 0 ? [] : [{ type: ContextEnd, context: { number: context, openedAt: contextOpenedAt, closedAt: at } }])
81
94
 
95
+ const compactTask = (task: string, at: string): SessionChange[] => {
96
+ const owner = tasks.get(task)
97
+ if (owner === undefined) return []
98
+ const next = { ...owner, context: owner.context + 1, contextOpenedAt: at }
99
+ tasks.set(task, next)
100
+ return [
101
+ { type: ContextEnd, context: { number: owner.context, openedAt: owner.contextOpenedAt ?? owner.openedAt, closedAt: at, task } },
102
+ { type: ContextStart, context: { number: next.context, openedAt: at, task } },
103
+ ]
104
+ }
105
+
82
106
  const endTurn = (at: string): SessionChange[] => {
83
107
  if (turn === undefined) return []
84
108
  const ended = { ...turn, closedAt: at }
@@ -86,17 +110,19 @@ export function createConversation(start: ConversationState): Conversation {
86
110
  return [{ type: TurnEnd, turn: ended }]
87
111
  }
88
112
 
89
- const endTask = (call: string, at: string): SessionChange[] => {
90
- const task = [...tasks.values()].find((each) => each.openedBy === call)
91
- if (task === undefined) {
113
+ const endTask = (call: string, at: string, only?: string): SessionChange[] => {
114
+ const opened = [...tasks.values()].filter((each) => each.openedBy === call && (only === undefined || each.id === only))
115
+ if (opened.length === 0) {
92
116
  if (agentCalls.has(call)) endedCalls.set(call, at)
93
117
  return []
94
118
  }
95
- if (task.closedAt !== undefined) return []
96
- const ended = { ...task, closedAt: at }
97
- tasks.set(task.id, ended)
98
- agentCalls.delete(call)
99
- return [{ type: TaskEnd, task: ended }]
119
+ if (only === undefined) agentCalls.delete(call)
120
+ return opened.flatMap((task) => {
121
+ if (task.closedAt !== undefined) return []
122
+ const ended = { ...task, closedAt: at }
123
+ tasks.set(task.id, ended)
124
+ return [...endTaskTurn(task.id, at), { type: TaskEnd, task: ended }]
125
+ })
100
126
  }
101
127
 
102
128
  const changeGoal = (text: string): SessionChange[] => {
@@ -109,30 +135,32 @@ export function createConversation(start: ConversationState): Conversation {
109
135
  const changes: SessionChange[] = []
110
136
  if (task === undefined && context === 0) changes.push(...startContext(row.at))
111
137
  if (task === undefined && row.type === 'system' && row.name === 'turn_duration') return [...changes, ...endTurn(row.at)]
112
- if (task === undefined && row.type === 'system' && row.name === 'compact_boundary') return [...changes, ...endContext(row.at), ...startContext(row.at)]
138
+ if (row.type === 'system' && row.name === 'compact_boundary') return [...changes, ...(task === undefined ? [...endContext(row.at), ...startContext(row.at)] : compactTask(task, row.at))]
113
139
  const owner = task === undefined ? undefined : tasks.get(task)
140
+ const placedIn = owner?.context ?? context
114
141
  for (const unplaced of messagesOf(row, task !== undefined)) {
115
- if (task === undefined && unplaced.type === Prompt) {
116
- changes.push(...endTurn(row.at))
117
- const opened: TurnData = { prompt: { ...unplaced, session: start.session, context, turn: unplaced.id }, context, openedAt: row.at }
118
- turn = opened
142
+ if (unplaced.type === Prompt) {
143
+ changes.push(...(task === undefined ? endTurn(row.at) : endTaskTurn(task, row.at)))
144
+ const opened: TurnData = { prompt: { ...unplaced, session: start.session, context: placedIn, turn: unplaced.id, ...(task === undefined ? {} : { task }) }, context: placedIn, openedAt: row.at, ...(owner?.closedAt === undefined ? {} : { closedAt: owner.closedAt }) }
145
+ if (task === undefined) turn = opened
146
+ else taskTurns.set(task, opened)
119
147
  changes.push({ type: TurnStart, turn: opened })
120
- if (goal === undefined) changes.push(...changeGoal(unplaced.text))
148
+ if (task === undefined && goal === undefined) changes.push(...changeGoal(unplaced.text))
121
149
  }
150
+ const open = task === undefined ? turn : taskTurns.get(task)
122
151
  const placed = {
123
152
  ...unplaced,
124
153
  session: start.session,
125
- context: owner?.context ?? context,
126
- ...(task === undefined && turn !== undefined ? { turn: turn.prompt.id } : {}),
154
+ context: placedIn,
155
+ ...(open === undefined ? {} : { turn: open.prompt.id }),
127
156
  ...(task === undefined ? {} : { task }),
128
157
  } as Message
129
158
  changes.push({ type: Message, message: placed })
130
- if (task !== undefined) continue
131
159
  if (placed.type === ToolCall && agentTools.includes(placed.name)) agentCalls.add(placed.callId)
132
160
  if (placed.type === ToolResult && agentCalls.has(placed.callId) && !placed.text.startsWith(asyncLaunch)) changes.push(...endTask(placed.callId, row.at))
133
161
  if (placed.type === Note && placed.from.type === Task) {
134
162
  const call = tagOf(textOf(row.content), 'tool-use-id')
135
- if (call !== undefined && agentCalls.has(call)) changes.push(...endTask(call, row.at))
163
+ if (call !== undefined && agentCalls.has(call)) changes.push(...endTask(call, row.at, tagOf(textOf(row.content), 'task-id')))
136
164
  }
137
165
  }
138
166
  return changes
@@ -144,13 +172,9 @@ export function createConversation(start: ConversationState): Conversation {
144
172
  agentCalls.add(data.openedBy)
145
173
  const changes: SessionChange[] = [{ type: TaskStart, task: started }]
146
174
  const endedAt = endedCalls.get(data.openedBy)
147
- if (endedAt !== undefined) {
148
- endedCalls.delete(data.openedBy)
149
- changes.push(...endTask(data.openedBy, endedAt))
150
- }
175
+ if (endedAt !== undefined) changes.push(...endTask(data.openedBy, endedAt, data.id))
151
176
  return changes
152
177
  },
153
- endContext,
154
178
  startContext,
155
179
  changeGoal,
156
180
  get state() {
@@ -163,6 +187,7 @@ export function createConversation(start: ConversationState): Conversation {
163
187
  agentCalls: [...agentCalls],
164
188
  endedCalls: Object.fromEntries(endedCalls),
165
189
  tasks: Object.fromEntries(tasks),
190
+ taskTurns: Object.fromEntries(taskTurns),
166
191
  }
167
192
  },
168
193
  }
@@ -1,9 +1,10 @@
1
+ import type { RecordPart } from '../session.js'
1
2
  import type { Task } from './task.js'
2
3
 
3
4
  export const GoalChange = 'goalChange'
4
5
 
5
- export type Goal = {
6
+ export type Goal = RecordPart & {
6
7
  readonly text: string
7
- update(change: { readonly text: string }): Promise<void>
8
8
  tasks(): Promise<Task[]>
9
+ update(change: { readonly text: string }): Promise<void>
9
10
  }