@cmodjs/core 4.0.2 → 4.1.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 (141) hide show
  1. package/docs/commands.md +43 -8
  2. package/docs/index.md +3 -3
  3. package/docs/install-steps.md +6 -6
  4. package/docs/jobs.md +2 -1
  5. package/docs/mod.md +1 -1
  6. package/docs/options.md +1 -1
  7. package/docs/permissions.md +1 -1
  8. package/docs/session.md +44 -13
  9. package/docs/testing.md +14 -10
  10. package/docs/ui.md +43 -11
  11. package/installer.d.ts +3 -2
  12. package/installer.d.ts.map +1 -1
  13. package/jobs/program.d.ts +1 -0
  14. package/jobs/program.d.ts.map +1 -1
  15. package/jobs/program.js +39 -23
  16. package/mod.d.ts +3 -2
  17. package/mod.d.ts.map +1 -1
  18. package/mod.js +1 -0
  19. package/package.json +1 -1
  20. package/records.d.ts +15 -1
  21. package/records.d.ts.map +1 -1
  22. package/records.js +39 -1
  23. package/register.d.ts.map +1 -1
  24. package/register.js +5 -2
  25. package/runtime/before.d.ts +6 -2
  26. package/runtime/before.d.ts.map +1 -1
  27. package/runtime/before.js +56 -18
  28. package/runtime/buttons.d.ts +10 -0
  29. package/runtime/buttons.d.ts.map +1 -0
  30. package/runtime/buttons.js +12 -0
  31. package/runtime/conversations.d.ts.map +1 -1
  32. package/runtime/conversations.js +134 -25
  33. package/runtime/engine.d.ts +2 -0
  34. package/runtime/engine.d.ts.map +1 -1
  35. package/runtime/grants.d.ts +1 -0
  36. package/runtime/grants.d.ts.map +1 -1
  37. package/runtime/grants.js +5 -2
  38. package/runtime/hotkeys.d.ts.map +1 -1
  39. package/runtime/hotkeys.js +7 -4
  40. package/runtime/installer.d.ts +9 -6
  41. package/runtime/installer.d.ts.map +1 -1
  42. package/runtime/installer.js +104 -47
  43. package/runtime/lifecycle.d.ts.map +1 -1
  44. package/runtime/lifecycle.js +121 -40
  45. package/runtime/pages.d.ts +3 -1
  46. package/runtime/pages.d.ts.map +1 -1
  47. package/runtime/pages.js +34 -10
  48. package/runtime/parts.d.ts +1 -0
  49. package/runtime/parts.d.ts.map +1 -1
  50. package/runtime/parts.js +6 -3
  51. package/runtime/programs.d.ts +1 -0
  52. package/runtime/programs.d.ts.map +1 -1
  53. package/runtime/programs.js +4 -1
  54. package/runtime/sessions.d.ts +2 -2
  55. package/runtime/sessions.d.ts.map +1 -1
  56. package/runtime/sessions.js +88 -58
  57. package/runtime/toaster.d.ts.map +1 -1
  58. package/runtime/toaster.js +23 -9
  59. package/runtime/ui.d.ts +21 -4
  60. package/runtime/ui.d.ts.map +1 -1
  61. package/runtime/ui.js +158 -58
  62. package/runtime/watches.d.ts.map +1 -1
  63. package/runtime/watches.js +18 -4
  64. package/runtime/widgets.d.ts +7 -5
  65. package/runtime/widgets.d.ts.map +1 -1
  66. package/runtime/widgets.js +29 -20
  67. package/session/context/turn/message/attached-file.d.ts +13 -0
  68. package/session/context/turn/message/attached-file.d.ts.map +1 -0
  69. package/session/context/turn/message/attached-file.js +21 -0
  70. package/session/context/turn/message/message.d.ts +3 -2
  71. package/session/context/turn/message/message.d.ts.map +1 -1
  72. package/session/context/turn/message/message.js +20 -6
  73. package/session/conversation.d.ts +14 -3
  74. package/session/conversation.d.ts.map +1 -1
  75. package/session/conversation.js +23 -4
  76. package/session/goal/task.d.ts +5 -0
  77. package/session/goal/task.d.ts.map +1 -1
  78. package/session/session.d.ts +35 -7
  79. package/session/session.d.ts.map +1 -1
  80. package/session/session.js +2 -1
  81. package/src/installer.ts +3 -2
  82. package/src/jobs/program.ts +41 -21
  83. package/src/mod.ts +3 -2
  84. package/src/records.ts +49 -1
  85. package/src/register.ts +9 -6
  86. package/src/runtime/before.ts +61 -22
  87. package/src/runtime/buttons.ts +22 -0
  88. package/src/runtime/conversations.ts +138 -32
  89. package/src/runtime/engine.ts +2 -0
  90. package/src/runtime/grants.ts +6 -2
  91. package/src/runtime/hotkeys.ts +7 -4
  92. package/src/runtime/installer.ts +144 -82
  93. package/src/runtime/lifecycle.ts +128 -43
  94. package/src/runtime/pages.ts +45 -8
  95. package/src/runtime/parts.ts +8 -4
  96. package/src/runtime/programs.ts +5 -1
  97. package/src/runtime/sessions.ts +83 -57
  98. package/src/runtime/toaster.ts +27 -9
  99. package/src/runtime/ui.ts +163 -61
  100. package/src/runtime/watches.ts +20 -5
  101. package/src/runtime/widgets.ts +34 -22
  102. package/src/session/context/turn/message/attached-file.ts +36 -0
  103. package/src/session/context/turn/message/message.ts +20 -7
  104. package/src/session/conversation.ts +30 -6
  105. package/src/session/goal/task.ts +6 -0
  106. package/src/session/session.ts +17 -7
  107. package/src/testing/fake-claude.ts +21 -6
  108. package/src/testing/fake-elements.ts +51 -8
  109. package/src/testing/fake-sessions.ts +60 -23
  110. package/src/testing.ts +86 -28
  111. package/src/ui/areas.ts +1 -0
  112. package/src/ui/components.ts +14 -8
  113. package/src/ui/elements.ts +6 -2
  114. package/src/ui/markdown.ts +3 -3
  115. package/src/ui/panes.ts +2 -2
  116. package/src/ui/styles.ts +6 -2
  117. package/testing/fake-claude.d.ts +3 -0
  118. package/testing/fake-claude.d.ts.map +1 -1
  119. package/testing/fake-claude.js +18 -5
  120. package/testing/fake-elements.d.ts.map +1 -1
  121. package/testing/fake-elements.js +46 -7
  122. package/testing/fake-sessions.d.ts +21 -10
  123. package/testing/fake-sessions.d.ts.map +1 -1
  124. package/testing/fake-sessions.js +58 -20
  125. package/testing.d.ts +8 -12
  126. package/testing.d.ts.map +1 -1
  127. package/testing.js +82 -21
  128. package/ui/areas.d.ts +4 -0
  129. package/ui/areas.d.ts.map +1 -1
  130. package/ui/components.d.ts +1 -0
  131. package/ui/components.d.ts.map +1 -1
  132. package/ui/components.js +11 -7
  133. package/ui/elements.d.ts +3 -0
  134. package/ui/elements.d.ts.map +1 -1
  135. package/ui/elements.js +6 -1
  136. package/ui/markdown.d.ts +2 -2
  137. package/ui/markdown.d.ts.map +1 -1
  138. package/ui/panes.d.ts +2 -1
  139. package/ui/panes.d.ts.map +1 -1
  140. package/ui/styles.d.ts +11 -2
  141. package/ui/styles.d.ts.map +1 -1
@@ -1,21 +1,22 @@
1
- import type { ElementTable, RenderElement } from 'claude-code'
2
- import { isObject, widgetsPath } from '../records.js'
1
+ import type { ElementTable, MarkdownProps, RenderElement } from 'claude-code'
2
+ import { isObject, storeChangedPath, widgetsPath } from '../records.js'
3
3
  import type { TurnData } from '../session/conversation.js'
4
4
  import { AbovePrompt, areas, BelowPrompt, TurnEnd, Working, type Area } from '../ui/areas.js'
5
5
  import { Box, drawWith } from '../ui/elements.js'
6
+ import type { StyledAt } from '../ui/styles.js'
6
7
  import { ifMissing } from '../utils/paths.js'
7
8
  import { messageOf } from '../utils/text.js'
8
9
  import type { Claude } from './claude.js'
9
10
  import { cmodPluginName } from './dependencies.js'
10
11
  import type { DrawnParts } from './parts.js'
11
12
  import type { Router } from './router.js'
12
- import { partMethods, type PartsUpdate, type WidgetDeclaration } from './ui.js'
13
+ import { partMethods, styledAt, viewportColumns, type PartsUpdate, type WidgetDeclaration } from './ui.js'
13
14
 
14
15
  export type WidgetRow = { readonly left: readonly string[]; readonly right: readonly string[] }
15
16
 
16
17
  export type WidgetLayout = { readonly areas: Readonly<Record<Area, readonly WidgetRow[]>>; readonly hidden: readonly string[] }
17
18
 
18
- export type PlacedWidget = { readonly key: string; readonly mod: string; readonly title: string; readonly areas: readonly Area[] }
19
+ export type PlacedWidget = { readonly key: string; readonly mod: string; readonly title: string; readonly areas: readonly Area[] } & Pick<WidgetDeclaration, 'place'>
19
20
 
20
21
  export type Move = 'up' | 'down' | 'left' | 'right'
21
22
 
@@ -32,7 +33,8 @@ const promptIndent = 4
32
33
 
33
34
  const transcriptIndent = 2
34
35
 
35
- const terminalColumns = 80
36
+ const widgetSizes = { fit: { flexShrink: 0 }, fill: { flexGrow: 1, flexShrink: 1, minWidth: 0 } } as const
37
+
36
38
 
37
39
  const separator = ''
38
40
 
@@ -44,7 +46,7 @@ export type Widgets = {
44
46
  reload(): Promise<void>
45
47
  recheck(): Promise<void>
46
48
  rows(area: Area, columns: number): RenderElement[]
47
- draw(area: Area, table: ElementTable, columns: number, turn?: TurnData): Promise<RenderElement[]>
49
+ draw(area: Area, table: ElementTable, at: StyledAt, turn?: TurnData): Promise<RenderElement[]>
48
50
  }
49
51
 
50
52
  export function layoutOf(value: unknown): WidgetLayout {
@@ -99,7 +101,9 @@ function declarationsOf(input: unknown): WidgetDeclaration[] {
99
101
  const listed = isObject(input) && Array.isArray(input['widgets']) ? (input['widgets'] as unknown[]) : []
100
102
  return listed.filter(isObject).flatMap((each) => {
101
103
  const known = Array.isArray(each['areas']) ? (each['areas'] as unknown[]).filter((area): area is Area => (areas as readonly unknown[]).includes(area)) : []
102
- return typeof each['id'] === 'string' && typeof each['title'] === 'string' && known.length > 0 ? [{ id: each['id'], title: each['title'], areas: known }] : []
104
+ const { side, size }: Record<string, unknown> = isObject(each['place']) ? each['place'] : {}
105
+ const place: NonNullable<WidgetDeclaration['place']> = { ...(side === 'left' || side === 'right' ? { side } : {}), ...(size === 'fit' || size === 'fill' ? { size } : {}) }
106
+ return typeof each['id'] === 'string' && typeof each['title'] === 'string' && known.length > 0 ? [{ id: each['id'], title: each['title'], areas: known, place }] : []
103
107
  })
104
108
  }
105
109
 
@@ -110,6 +114,7 @@ export function serveWidgets({
110
114
  report,
111
115
  turnClosedBy,
112
116
  parts,
117
+ markdownIn,
113
118
  }: {
114
119
  readonly claude: Claude
115
120
  readonly router: Router
@@ -117,6 +122,7 @@ export function serveWidgets({
117
122
  readonly report: (id: string, message: string, error: unknown) => void
118
123
  readonly turnClosedBy: (rowId: string) => TurnData | undefined
119
124
  readonly parts: DrawnParts
125
+ readonly markdownIn: (table: ElementTable, at: StyledAt) => ((props: MarkdownProps) => RenderElement) | undefined
120
126
  }): Widgets {
121
127
  const path = widgetsPath(store)
122
128
  const declared = new Map<string, readonly WidgetDeclaration[]>()
@@ -136,7 +142,7 @@ export function serveWidgets({
136
142
 
137
143
  const redraw = () => claude.ui.invalidate('ui.render')
138
144
 
139
- const placed = (): PlacedWidget[] => [...builtInWidgets, ...[...declared].flatMap(([mod, list]) => list.map(({ id, title, areas: listed }) => ({ key: `${mod}/${id}`, mod, title, areas: listed })))]
145
+ const placed = (): PlacedWidget[] => [...builtInWidgets, ...[...declared].flatMap(([mod, list]) => list.map(({ id, title, areas: listed, place }) => ({ key: `${mod}/${id}`, mod, title, areas: listed, ...(place === undefined ? {} : { place }) })))]
140
146
 
141
147
  const change = (edit: (current: WidgetLayout) => WidgetLayout) => {
142
148
  saving = saving
@@ -148,6 +154,7 @@ export function serveWidgets({
148
154
  if (next !== current) {
149
155
  const kept = isObject(file) ? file : {}
150
156
  await claude.fs.write(path, `${JSON.stringify({ ...kept, ...next, areas: { ...(isObject(kept['areas']) ? kept['areas'] : {}), ...next.areas } }, null, 2)}\n`)
157
+ await claude.fs.write(storeChangedPath(store), '')
151
158
  stored = next
152
159
  }
153
160
  place()
@@ -164,9 +171,11 @@ export function serveWidgets({
164
171
  const withNewWidgets = (current: WidgetLayout) =>
165
172
  placed()
166
173
  .filter(({ key, mod }) => mod !== 'claude' && placedIn(current, key) === undefined && !placedKeys.has(key))
167
- .reduce((next, { key, areas: [area = AbovePrompt] }) => {
174
+ .reduce((next, { key, areas: [area = AbovePrompt], place }) => {
168
175
  const [first, ...rest] = next.areas[area]
169
- return { ...next, areas: { ...next.areas, [area]: [{ ...first, left: [...(first?.left ?? []), key], right: first?.right ?? [] }, ...rest] } }
176
+ const row = { ...first, left: first?.left ?? [], right: first?.right ?? [] }
177
+ const side = place?.side ?? 'left'
178
+ return { ...next, areas: { ...next.areas, [area]: [{ ...row, [side]: [...row[side], key] }, ...rest] } }
170
179
  }, current)
171
180
 
172
181
  const ask = async (mod: string) => {
@@ -215,7 +224,9 @@ export function serveWidgets({
215
224
 
216
225
  const elementFor = (key: string, area: Area, columns: number, turn: TurnData | undefined): RenderElement | undefined => {
217
226
  const widget = widgetOf(key)
218
- return widget === undefined ? undefined : parts.element(widget.mod, cacheKey(key, area, columns, turn), `${key}:`, nameOf(widget), `widget:${key}`)
227
+ if (widget === undefined) return undefined
228
+ const element = parts.element(widget.mod, cacheKey(key, area, columns, turn), `${key}:`, nameOf(widget), `widget:${key}`)
229
+ return element === undefined ? undefined : Box({ ...widgetSizes[widget.place?.size ?? 'fit'], children: element })
219
230
  }
220
231
 
221
232
  const isHidden = (key: string) => layout.hidden.includes(key)
@@ -227,19 +238,20 @@ export function serveWidgets({
227
238
  const side = (keys: readonly string[]) => shown(keys).map((key) => elementFor(key, area, columns, turn)).filter((each) => each !== undefined)
228
239
  const left = side(row.left)
229
240
  const right = side(row.right)
230
- return left.length + right.length === 0 ? [] : [Box({ justifyContent: 'space-between', width: columns, children: [Box({ gap: 2, children: left }), Box({ gap: 2, children: right })] })]
241
+ const group = (keys: readonly string[], children: readonly RenderElement[]) => Box({ flexShrink: shown(keys).some((key) => widgetOf(key)?.place?.size === 'fill') ? 1 : 0, minWidth: 0, columnGap: 2, children })
242
+ return left.length + right.length === 0 ? [] : [Box({ justifyContent: 'space-between', width: columns, columnGap: 2, children: [group(row.left, left), group(row.right, right)] })]
231
243
  })
232
244
 
233
245
  const keysIn = (area: Area) => layout.areas[area].flatMap((row) => shown([...row.left, ...row.right]))
234
246
 
235
- const draw = async (area: Area, table: ElementTable, columns: number, turn?: TurnData): Promise<RenderElement[]> => {
236
- await Promise.all(keysIn(area).map((key) => fill(key, area, columns, turn)))
237
- return drawWith(table, () => rowsNow(area, columns, turn))
247
+ const draw = async (area: Area, table: ElementTable, at: StyledAt, turn?: TurnData): Promise<RenderElement[]> => {
248
+ await Promise.all(keysIn(area).map((key) => fill(key, area, at.columns, turn)))
249
+ return drawWith(table, () => rowsNow(area, at.columns, turn), markdownIn(table, at))
238
250
  }
239
251
 
240
252
  const rows = (area: Area, columns: number): RenderElement[] => {
241
253
  const started = keysIn(area).flatMap((key) => fill(key, area, columns, undefined) ?? [])
242
- if (started.length > 0) void Promise.all(started).then(() => claude.ui.invalidate('ui.render'))
254
+ if (started.length > 0) void Promise.all(started).then(redraw)
243
255
  return rowsNow(area, columns, undefined)
244
256
  }
245
257
 
@@ -249,17 +261,17 @@ export function serveWidgets({
249
261
 
250
262
  router.add('ui.render', async (e, next) => {
251
263
  const table = claude.ui.resolve(e)
252
- const columns = (e.viewport?.columns ?? terminalColumns) - transcriptIndent
253
- if (e.component === 'AbovePrompt') return e.props.hasSurvey ? next(e) : below(table, await next(e), await draw(AbovePrompt, table, e.props.bodyColumns))
264
+ const inTranscript = styledAt(e, viewportColumns(e) - transcriptIndent)
265
+ if (e.component === 'AbovePrompt') return e.props.hasSurvey ? next(e) : below(table, await next(e), await draw(AbovePrompt, table, styledAt(e, e.props.bodyColumns)))
254
266
  if (e.component === 'PromptHint') {
255
- const added = await draw(BelowPrompt, table, (e.viewport?.columns ?? terminalColumns) - promptIndent)
267
+ const added = await draw(BelowPrompt, table, styledAt(e, viewportColumns(e) - promptIndent))
256
268
  return isHidden(claudeHint) ? column(table, added) : below(table, await next(e), added)
257
269
  }
258
270
  if (e.component === 'SessionMode') return isHidden(claudeModes) ? column(table, []) : next(e)
259
- if (e.component === 'Spinner') return below(table, await next(e), await draw(Working, table, columns))
271
+ if (e.component === 'Spinner') return below(table, await next(e), await draw(Working, table, inTranscript))
260
272
  if (e.component === 'TurnDuration') {
261
273
  const turn = turnClosedBy(e.requestId)
262
- return below(table, await next(e), turn === undefined ? [] : await draw(TurnEnd, table, columns, turn))
274
+ return below(table, await next(e), turn === undefined ? [] : await draw(TurnEnd, table, inTranscript, turn))
263
275
  }
264
276
  return next(e)
265
277
  })
@@ -272,7 +284,7 @@ export function serveWidgets({
272
284
  const listed = declarationsOf(listing)
273
285
  const isNewList = JSON.stringify(listed) !== JSON.stringify(declared.get(mod) ?? [])
274
286
  declared.set(mod, listed)
275
- if (isNewList) place()
287
+ if (isNewList) return place()
276
288
  }
277
289
  claude.ui.invalidate('ui.render')
278
290
  },
@@ -0,0 +1,36 @@
1
+ import type { MessageOf } from './message.js'
2
+
3
+ export const AttachedFile = 'attachedFile'
4
+
5
+ export type AttachmentType = 'contents' | 'reference' | 'change'
6
+
7
+ export type AttachedFile = MessageOf<typeof AttachedFile> & {
8
+ readonly path: string
9
+ readonly attachmentType: AttachmentType
10
+ readonly text: string
11
+ }
12
+
13
+ export const attachmentTypes: Readonly<Record<string, AttachmentType>> = {
14
+ file: 'contents',
15
+ compact_file_reference: 'reference',
16
+ edited_text_file: 'change',
17
+ }
18
+
19
+ export function attachmentTypeOf(name: string | undefined): AttachmentType | undefined {
20
+ return name !== undefined && Object.hasOwn(attachmentTypes, name) ? attachmentTypes[name] : undefined
21
+ }
22
+
23
+ export function attachmentNameOf(type: AttachmentType): string {
24
+ const name = Object.keys(attachmentTypes).find((each) => attachmentTypes[each] === type)
25
+ if (name === undefined) throw new Error(`Claude Code writes no attachment row for a ${type} file.`)
26
+ return name
27
+ }
28
+
29
+ const readPath = /^Called the Read tool with the following input: \{.*?"file_path":\s*("(?:[^"\\]|\\["\\/bfnrt]|\\u[0-9a-fA-F]{4})*")/m
30
+
31
+ const noted = /^Note: (.+?) (?:was read before the last conversation was summarized|changed on disk since you last read it)/m
32
+
33
+ export function attachedPathOf(text: string): string {
34
+ const read = readPath.exec(text)?.[1]
35
+ return read === undefined ? (noted.exec(text)?.[1] ?? '') : (JSON.parse(read) as string)
36
+ }
@@ -1,5 +1,6 @@
1
1
  import { isObject } from '../../../../records.js'
2
2
  import { Task } from '../../../goal/task.js'
3
+ import { AttachedFile, attachedPathOf, attachmentTypeOf } from './attached-file.js'
3
4
  import { InstructionFile, isRulePath } from './instruction-file.js'
4
5
  import { modNote, modNoteBlock, modNotesIn, Note } from './note.js'
5
6
  import { Notice } from './notice.js'
@@ -12,7 +13,7 @@ import { ToolResult } from './tool-result.js'
12
13
 
13
14
  export const Message = 'message'
14
15
 
15
- export type MessageType = typeof Prompt | typeof Note | typeof Reply | typeof ToolCall | typeof ToolResult | typeof Skill | typeof InstructionFile | typeof Notice
16
+ export type MessageType = typeof Prompt | typeof Note | typeof Reply | typeof ToolCall | typeof ToolResult | typeof Skill | typeof InstructionFile | typeof AttachedFile | typeof Notice
16
17
 
17
18
  export type MessageOf<Type extends MessageType> = {
18
19
  readonly id: string
@@ -24,7 +25,7 @@ export type MessageOf<Type extends MessageType> = {
24
25
  readonly task?: string
25
26
  }
26
27
 
27
- export type Message = Prompt | Note | Reply | ToolCall | ToolResult | Skill | InstructionFile | Notice
28
+ export type Message = Prompt | Note | Reply | ToolCall | ToolResult | Skill | InstructionFile | AttachedFile | Notice
28
29
 
29
30
  export type Placed = 'session' | 'context' | 'turn' | 'task'
30
31
 
@@ -44,7 +45,7 @@ export type Row = {
44
45
  readonly model?: string
45
46
  }
46
47
 
47
- export const messageTypes: readonly MessageType[] = [Prompt, Note, Reply, ToolCall, ToolResult, Skill, InstructionFile, Notice]
48
+ export const messageTypes: readonly MessageType[] = [Prompt, Note, Reply, ToolCall, ToolResult, Skill, InstructionFile, AttachedFile, Notice]
48
49
 
49
50
  export const skillPrefix = 'Base directory for this skill:'
50
51
 
@@ -63,8 +64,9 @@ export function rowOfLine(row: Readonly<Record<string, unknown>>): Row | undefin
63
64
  if (typeof uuid !== 'string' || typeof timestamp !== 'string' || typeof type !== 'string' || !rowTypes.includes(type)) return undefined
64
65
  const message = isObject(row['message']) ? row['message'] : {}
65
66
  const attachment = isObject(row['attachment']) ? row['attachment'] : {}
66
- const content = type === 'attachment' ? attachment['content'] : type === 'system' ? row['content'] : message['content']
67
67
  const name = type === 'attachment' ? attachment['type'] : row['subtype']
68
+ const content = type === 'attachment' ? attachedContentOf(attachment) : type === 'system' ? row['content'] : message['content']
69
+ const path = attachment['path'] ?? attachment['filename']
68
70
  return {
69
71
  id: uuid,
70
72
  at: timestamp,
@@ -75,15 +77,21 @@ export function rowOfLine(row: Readonly<Record<string, unknown>>): Row | undefin
75
77
  content: blocksOf(content),
76
78
  ...(row['origin'] === undefined ? {} : { origin: row['origin'] }),
77
79
  ...(typeof row['cwd'] === 'string' ? { cwd: row['cwd'] } : {}),
78
- ...(typeof attachment['path'] === 'string' ? { path: attachment['path'] } : {}),
80
+ ...(typeof path === 'string' ? { path } : {}),
79
81
  ...(typeof message['model'] === 'string' ? { model: message['model'] } : {}),
80
82
  }
81
83
  }
82
84
 
85
+ function attachedContentOf(attachment: Readonly<Record<string, unknown>>): unknown {
86
+ if (attachment['type'] === 'edited_text_file') return attachment['snippet']
87
+ const file = isObject(attachment['content']) ? attachment['content']['file'] : undefined
88
+ return attachment['type'] === 'file' && isObject(file) ? file['content'] : attachment['content']
89
+ }
90
+
83
91
  export function rowOfAppend(append: { readonly uuid: string; readonly door: string; readonly origin: unknown; readonly message: { readonly type: Row['type']; readonly name?: string; readonly isMeta?: true; readonly content: readonly unknown[] } }, at: string, cwd: string): Row {
84
92
  const { message, origin } = append
85
93
  const text = textOf(message.content)
86
- const path = message.type === 'attachment' ? instructionPathOf(text) || undefined : undefined
94
+ const path = message.type === 'attachment' ? (attachmentTypeOf(message.name) !== undefined ? attachedPathOf(text) : instructionPathOf(text)) || undefined : undefined
87
95
  const model = isObject(origin) && origin['kind'] === 'model' && typeof origin['model'] === 'string' ? origin['model'] : undefined
88
96
  return {
89
97
  id: append.uuid,
@@ -113,7 +121,11 @@ function unplacedOf(row: Row, isInTask: boolean): Unplaced[] {
113
121
  const notesFrom = (each: readonly { readonly mod: string; readonly text: string }[]): Unplaced[] => each.map((note) => ({ ...base, type: Note, text: note.text, from: { type: Mod, name: note.mod } }))
114
122
  if (row.type === 'system') return row.name === 'turn_duration' || row.name === 'compact_boundary' || text === '' ? [] : [{ ...base, type: Notice, text }]
115
123
  if (row.type === 'attachment' && row.name === 'hook_additional_context') return notesFrom(row.content.flatMap((entry) => modNotesIn(typeof entry === 'string' ? entry : textOf([entry]))))
116
- if (row.type === 'attachment') return row.name === 'nested_memory' ? [{ ...base, type: InstructionFile, path: row.path ?? '', text, isRule: isRulePath(row.path ?? '') }] : []
124
+ if (row.type === 'attachment' && row.name === 'nested_memory') return [{ ...base, type: InstructionFile, path: row.path ?? '', text, isRule: isRulePath(row.path ?? '') }]
125
+ if (row.type === 'attachment') {
126
+ const attachmentType = attachmentTypeOf(row.name)
127
+ return attachmentType === undefined ? [] : [{ ...base, type: AttachedFile, path: row.path ?? '', attachmentType, text }]
128
+ }
117
129
  if (isModNotesRow(row)) return notesFrom(modNotesIn(text))
118
130
  if (row.type === 'assistant') {
119
131
  return row.content.filter(isObject).flatMap((block): Unplaced[] => {
@@ -212,6 +224,7 @@ export function searchText(message: Unplaced): string {
212
224
  if (message.type === ToolCall) return [message.name, ...stringsIn(message.input).map((value) => value.slice(0, toolValueChars))].join('\n')
213
225
  if (message.type === ToolResult) return ''
214
226
  if (message.type === InstructionFile) return `${message.path}\n${message.text}`
227
+ if (message.type === AttachedFile) return message.path
215
228
  return message.text
216
229
  }
217
230
 
@@ -6,7 +6,7 @@ import { ToolCall } from './context/turn/message/tool-call.js'
6
6
  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 } from './goal/goal.js'
9
- import { Task, TaskEnd, TaskStart } from './goal/task.js'
9
+ import { Task, TaskEnd, TaskStart, type TaskStatus } from './goal/task.js'
10
10
  import type { UsageChange, Usage } from './session.js'
11
11
 
12
12
  export type SessionData = {
@@ -28,7 +28,23 @@ export type TurnData = Pick<Turn, 'prompt' | 'context' | 'openedAt' | 'closedAt'
28
28
 
29
29
  export type TurnEnding = Pick<Turn, 'answer' | 'reason' | 'failure'>
30
30
 
31
- export type TaskData = Pick<Task, 'id' | 'title' | 'openedBy' | 'openedAt' | 'closedAt'> & { readonly agentType: string; readonly model?: string; readonly parent?: string }
31
+ export type TaskData = Pick<Task, 'id' | 'title' | 'openedBy' | 'openedAt' | 'closedAt' | 'status' | 'name' | 'spawnedBy' | 'isBackground'> & { readonly agentType?: string; readonly model?: string; readonly parent?: string; readonly forkedFrom?: string }
32
+
33
+ export const forkAgentType = 'fork'
34
+
35
+ export function ownerOfSpawn(agentType: string | undefined, session: string): Pick<TaskData, 'agentType' | 'forkedFrom'> {
36
+ return agentType === forkAgentType ? { forkedFrom: session } : { agentType: agentType ?? 'general-purpose' }
37
+ }
38
+
39
+ type Agent = { readonly id: string; readonly status: TaskStatus; readonly name?: string; readonly spawnedBy?: string }
40
+
41
+ export function tasksWithAgents(tasks: readonly TaskData[], agents: readonly Agent[]): TaskData[] {
42
+ const byId = new Map(agents.map((agent) => [agent.id, agent]))
43
+ return tasks.map((task) => {
44
+ const agent = byId.get(task.id)
45
+ return agent === undefined ? task : { ...task, status: agent.status, ...(agent.name === undefined ? {} : { name: agent.name }), ...(agent.spawnedBy === undefined ? {} : { spawnedBy: agent.spawnedBy }) }
46
+ })
47
+ }
32
48
 
33
49
  export type RunningTask = TaskData & { readonly context: number; readonly contextOpenedAt?: string }
34
50
 
@@ -60,6 +76,7 @@ type OpenCall = Pick<ToolCall, 'name' | 'input' | 'cwd'>
60
76
  export type Conversation = {
61
77
  add(row: Row, task?: string): SessionChange[]
62
78
  startTask(task: TaskData): SessionChange[]
79
+ endTask(task: TaskData, at: string): SessionChange[]
63
80
  startContext(at: string): SessionChange[]
64
81
  endTurn(at: string, ending: TurnEnding, task?: string): SessionChange[]
65
82
  changeGoal(text: string): SessionChange[]
@@ -117,7 +134,7 @@ export function createConversation(start: ConversationState): Conversation {
117
134
  return [{ type: TurnEnd, turn: ended }]
118
135
  }
119
136
 
120
- const endTask = (call: string, at: string, only?: string): SessionChange[] => {
137
+ const endCall = (call: string, at: string, only?: string): SessionChange[] => {
121
138
  const opened = [...tasks.values()].filter((each) => each.openedBy === call && (only === undefined || each.id === only))
122
139
  if (opened.length === 0) {
123
140
  if (agentCalls.has(call)) endedCalls.set(call, at)
@@ -168,10 +185,10 @@ export function createConversation(start: ConversationState): Conversation {
168
185
  } as Message
169
186
  changes.push({ type: Message, message: placed })
170
187
  if (placed.type === ToolCall && agentTools.includes(placed.name)) agentCalls.add(placed.callId)
171
- if (placed.type === ToolResult && agentCalls.has(placed.callId) && !String(placed.output).startsWith(asyncLaunch)) changes.push(...endTask(placed.callId, row.at))
188
+ if (placed.type === ToolResult && agentCalls.has(placed.callId) && !String(placed.output).startsWith(asyncLaunch)) changes.push(...endCall(placed.callId, row.at))
172
189
  if (placed.type === Note && placed.from.type === Task) {
173
190
  const call = tagOf(textOf(row.content), 'tool-use-id')
174
- if (call !== undefined && agentCalls.has(call)) changes.push(...endTask(call, row.at, tagOf(textOf(row.content), 'task-id')))
191
+ if (call !== undefined && agentCalls.has(call)) changes.push(...endCall(call, row.at, tagOf(textOf(row.content), 'task-id')))
175
192
  }
176
193
  }
177
194
  return changes
@@ -183,9 +200,16 @@ export function createConversation(start: ConversationState): Conversation {
183
200
  agentCalls.add(data.openedBy)
184
201
  const changes: SessionChange[] = [{ type: TaskStart, task: started }]
185
202
  const endedAt = endedCalls.get(data.openedBy)
186
- if (endedAt !== undefined) changes.push(...endTask(data.openedBy, endedAt, data.id))
203
+ if (endedAt !== undefined) changes.push(...endCall(data.openedBy, endedAt, data.id))
187
204
  return changes
188
205
  },
206
+ endTask(data, at) {
207
+ const open = tasks.get(data.id) ?? { ...data, context }
208
+ if (open.closedAt !== undefined) return []
209
+ const ended = { ...open, closedAt: at }
210
+ tasks.set(data.id, ended)
211
+ return [...endTaskTurn(data.id, at), { type: TaskEnd, task: ended }]
212
+ },
189
213
  startContext,
190
214
  endTurn: (at, ending, task) => (task === undefined ? endTurn(at, ending) : endTaskTurn(task, at, ending)),
191
215
  changeGoal,
@@ -7,6 +7,8 @@ export const TaskStart = 'taskStart'
7
7
 
8
8
  export const TaskEnd = 'taskEnd'
9
9
 
10
+ export type TaskStatus = 'pending' | 'running' | 'waiting' | 'idle' | 'completed' | 'failed' | 'killed'
11
+
10
12
  export type Task = RecordPart & {
11
13
  readonly id: string
12
14
  readonly title: string
@@ -14,6 +16,10 @@ export type Task = RecordPart & {
14
16
  readonly openedBy: string
15
17
  readonly openedAt: string
16
18
  readonly closedAt?: string
19
+ readonly status?: TaskStatus
20
+ readonly name?: string
21
+ readonly spawnedBy?: string
22
+ readonly isBackground?: true
17
23
  tasks(): Promise<Task[]>
18
24
  messages(): Promise<Message[]>
19
25
  }
@@ -2,6 +2,7 @@ import type { Subscription } from '../utils/subscription.js'
2
2
  import type { Matcher } from '../utils/matcher.js'
3
3
  import type { Sender } from './context/turn/message/sender.js'
4
4
  import type { Context, ContextEnd, ContextStart } from './context/context.js'
5
+ import type { AttachedFile } from './context/turn/message/attached-file.js'
5
6
  import type { InstructionFile } from './context/turn/message/instruction-file.js'
6
7
  import type { Message, MessageType } from './context/turn/message/message.js'
7
8
  import type { Note } from './context/turn/message/note.js'
@@ -31,6 +32,7 @@ export type SessionPayload = {
31
32
  readonly toolResult: ToolResult
32
33
  readonly skill: Skill
33
34
  readonly instructionFile: InstructionFile
35
+ readonly attachedFile: AttachedFile
34
36
  readonly notice: Notice
35
37
  readonly contextStart: Context
36
38
  readonly contextEnd: Context
@@ -49,12 +51,13 @@ export type BeforeAnswer = {
49
51
  readonly note: { readonly text: string }
50
52
  readonly skill: { readonly text: string }
51
53
  readonly instructionFile: { readonly text: string } | Refusal
54
+ readonly attachedFile: { readonly text: string } | Refusal
52
55
  readonly reply: { readonly text: string }
53
- readonly toolCall: { readonly input: Readonly<Record<string, unknown>> } | { readonly result: string } | Refusal
56
+ readonly toolCall: { readonly input?: Readonly<Record<string, unknown>>; readonly note?: string } | { readonly result: string; readonly note?: string } | Refusal
54
57
  readonly toolResult: { readonly output?: unknown; readonly isError?: true; readonly note?: string }
55
58
  readonly notice: { readonly text: string }
56
- readonly taskStart: { readonly owner: { readonly model: string } } | Refusal
57
- readonly turnEnd: Refusal
59
+ readonly taskStart: { readonly prompt?: string; readonly title?: string; readonly owner?: { readonly agentType?: string; readonly model?: string; readonly cwd?: string }; readonly isAsync?: boolean } | Refusal
60
+ readonly turnEnd: { readonly note: string } | Refusal
58
61
  readonly contextEnd: Refusal
59
62
  }
60
63
 
@@ -63,9 +66,12 @@ export type BeforeEvent = keyof BeforeAnswer
63
66
  export type Usage = {
64
67
  readonly model: string
65
68
  readonly contextWindow: { readonly tokenCount?: number; readonly size?: number; readonly percent?: number }
69
+ readonly rateLimits?: readonly RateLimit[]
66
70
  readonly cost?: { readonly usd: number }
67
71
  }
68
72
 
73
+ export type RateLimit = { readonly type: string; readonly percent: number; readonly resetsAt?: string }
74
+
69
75
  export type Surface = 'terminal' | 'desktop' | 'mobile' | 'vscode'
70
76
 
71
77
  export const sessionEvents = {
@@ -77,6 +83,7 @@ export const sessionEvents = {
77
83
  ToolResult: 'toolResult',
78
84
  Skill: 'skill',
79
85
  InstructionFile: 'instructionFile',
86
+ AttachedFile: 'attachedFile',
80
87
  Notice: 'notice',
81
88
  ContextStart: 'contextStart',
82
89
  ContextEnd: 'contextEnd',
@@ -88,7 +95,7 @@ export const sessionEvents = {
88
95
  UsageChange: 'usageChange',
89
96
  } as const satisfies Readonly<Record<string, SessionEvent>>
90
97
 
91
- export const beforeEvents = ['Prompt', 'Note', 'Skill', 'InstructionFile', 'Reply', 'ToolCall', 'ToolResult', 'Notice', 'TaskStart', 'TurnEnd', 'ContextEnd'] as const satisfies readonly (keyof typeof sessionEvents)[]
98
+ export const beforeEvents = ['Prompt', 'Note', 'Skill', 'InstructionFile', 'AttachedFile', 'Reply', 'ToolCall', 'ToolResult', 'Notice', 'TaskStart', 'TurnEnd', 'ContextEnd'] as const satisfies readonly (keyof typeof sessionEvents)[]
92
99
 
93
100
  export type EventName = keyof typeof sessionEvents
94
101
 
@@ -138,6 +145,7 @@ export type TaskStartArgs = {
138
145
  readonly title: string
139
146
  readonly name?: string
140
147
  readonly cwd?: string
148
+ readonly isBackground?: boolean
141
149
  }
142
150
 
143
151
  export type ToolCallArgs = {
@@ -164,12 +172,14 @@ export type Session = RecordPart & {
164
172
  messages(options?: { readonly after?: string; readonly last?: number }): Promise<Message[]>
165
173
  send(message: SessionSend): Promise<void>
166
174
  startTask(task: TaskStartArgs): Promise<Task>
175
+ fork(task: Pick<TaskStartArgs, 'prompt' | 'title'>): Promise<Task>
176
+ clone(task: Pick<TaskStartArgs, 'prompt' | 'title'>): Promise<Task>
167
177
  callTool(call: ToolCallArgs): Promise<ToolResult>
168
178
  usage(): Promise<Usage>
169
179
  surfaces(): Promise<Surface[]>
170
180
  }
171
181
 
172
- export type TaskOwner = Omit<Session, 'startTask' | 'callTool'>
182
+ export type TaskOwner = Omit<Session, 'startTask' | 'callTool' | 'fork' | 'clone'>
173
183
 
174
184
  type ProcessEvent = { readonly on: string; readonly before?: string }
175
185
 
@@ -305,9 +315,9 @@ export type ProcessPayload = {
305
315
  export type ProcessAsked = Omit<ProcessPayload, 'SessionStart'> & { readonly SessionStart: SessionStartAsked }
306
316
 
307
317
  export type ProcessAnswer = {
308
- readonly ProjectSetup: Record<never, never>
318
+ readonly ProjectSetup: { readonly note: string }
309
319
  readonly SessionStart: { readonly note?: string; readonly title?: string; readonly reloadSkills?: true; readonly firstPrompt?: string }
310
- readonly ModelChange: { readonly ask: string } | Refusal
320
+ readonly ModelChange: { readonly ask: string } | { readonly note: string } | Refusal
311
321
  readonly WorktreeCreate: { readonly result: string }
312
322
  readonly Elicitation: Refusal
313
323
  readonly ElicitationResponse: Refusal
@@ -20,7 +20,7 @@ export type Fakes = {
20
20
  config: { list?: Claude['config']['list'] }
21
21
  command: { list?: Claude['command']['list']; run?: Claude['command']['run'] }
22
22
  ui: { ask?: Claude['ui']['ask']; open?: Claude['ui']['open']; scroll?: Claude['ui']['scroll']; copy?: Claude['ui']['copy']; focus?: Claude['ui']['focus'] }
23
- session: { messages?: Claude['session']['messages']; append?: Claude['session']['append']; send?: Claude['session']['send'] }
23
+ session: { messages?: Claude['session']['messages']; append?: Claude['session']['append']; send?: Claude['session']['send']; surfaces?: Claude['session']['surfaces']; usage?: Claude['session']['usage'] }
24
24
  prompt: { submit?: Claude['prompt']['submit'] }
25
25
  model: { complete?: Claude['model']['complete'] }
26
26
  agent: { list?: Claude['agent']['list']; spawn?: Claude['agent']['spawn'] }
@@ -49,6 +49,7 @@ export type FakeClaude = {
49
49
  readonly shown: Shown
50
50
  readonly store: Map<string, unknown>
51
51
  settle(): Promise<void>
52
+ placePane(id: string): void
52
53
  }
53
54
 
54
55
  export function calledAnswer(caller: string, answer: EventResult<'cmod.call'>): unknown {
@@ -80,6 +81,16 @@ export function fakeClaude(plugin: { readonly name: string; readonly root: strin
80
81
  return async (...args: Args): Promise<Result> => run(...args)
81
82
  }
82
83
 
84
+ const cmodCall = rejected('cmod.call', () => fakes.cmod.call ?? cmodTakesParts)
85
+
86
+ const clonedAsClaudeCodeDoes = <Args,>(args: Args): Args => {
87
+ try {
88
+ return structuredClone(args)
89
+ } catch (error) {
90
+ throw new Error(`interface.call: arguments are not plain data: ${error instanceof Error ? error.message : String(error)}`)
91
+ }
92
+ }
93
+
83
94
  const cmodTakesParts: Claude['cmod']['call'] = async (input) => {
84
95
  if (input.to === cmodPluginName && input.method === partMethods.changed) return null
85
96
  throw new Error(`cmod.call(${JSON.stringify(input)}) has no fake answer. Set fakes.cmod.call on the tested mod.`)
@@ -118,7 +129,7 @@ export function fakeClaude(plugin: { readonly name: string; readonly root: strin
118
129
  lines.push(text)
119
130
  },
120
131
  notice: () => undefined,
121
- invalidate: () => undefined,
132
+ invalidate: (event) => void calls.push({ call: 'ui.invalidate', args: [event] }),
122
133
  ask: rejected('ui.ask', () => fakes.ui.ask),
123
134
  open: async (pane) => {
124
135
  calls.push({ call: 'ui.open', args: [pane] })
@@ -188,8 +199,8 @@ export function fakeClaude(plugin: { readonly name: string; readonly root: strin
188
199
  root: async () => plugin.root,
189
200
  cwd: async () => plugin.root,
190
201
  model: async () => 'test-model',
191
- usage: async () => ({ startedAt: Date.parse('2026-01-01T00:00:00.000Z'), context: { window: 200000 }, rateLimits: [], cost: { usd: 0 } }),
192
- surfaces: async () => ['terminal'],
202
+ usage: () => fakes.session.usage?.() ?? Promise.resolve({ startedAt: Date.parse('2026-01-01T00:00:00.000Z'), context: { window: 200000 }, rateLimits: [], cost: { usd: 0 } }),
203
+ surfaces: () => fakes.session.surfaces?.() ?? Promise.resolve(['terminal']),
193
204
  messages: rejected('session.messages', () => fakes.session.messages as ((args?: unknown) => Promise<unknown>) | undefined) as Claude['session']['messages'],
194
205
  append: rejected('session.append', () => fakes.session.append ?? appendNote),
195
206
  send: rejected('session.send', () => fakes.session.send ?? (async () => ({ isDelivered: true as const }))),
@@ -217,7 +228,7 @@ export function fakeClaude(plugin: { readonly name: string; readonly root: strin
217
228
  dataHome: async () => undefined,
218
229
  configHome: async () => undefined,
219
230
  },
220
- cmod: { call: rejected('cmod.call', () => fakes.cmod.call ?? cmodTakesParts) },
231
+ cmod: { call: async (args) => cmodCall(clonedAsClaudeCodeDoes(args)) },
221
232
  }
222
233
 
223
234
  const settle = async () => {
@@ -228,7 +239,11 @@ export function fakeClaude(plugin: { readonly name: string; readonly root: strin
228
239
  } while (calls.length !== seen)
229
240
  }
230
241
 
231
- return { claude, calls, fakes, shown, store, settle }
242
+ const placePane = (id: string) => {
243
+ if (unplacedPanes.delete(id)) shown.openPanes.add(id)
244
+ }
245
+
246
+ return { claude, calls, fakes, shown, store, settle, placePane }
232
247
  }
233
248
 
234
249
  declare function setTimeout(fn: () => void, ms: number): unknown