@cmodjs/core 4.0.3 → 4.1.1

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 (143) hide show
  1. package/docs/commands.md +44 -9
  2. package/docs/index.md +3 -3
  3. package/docs/install-steps.md +8 -8
  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 +46 -13
  9. package/docs/testing.md +15 -11
  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 +8 -5
  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 +3 -0
  32. package/runtime/conversations.d.ts.map +1 -1
  33. package/runtime/conversations.js +164 -27
  34. package/runtime/engine.d.ts +2 -0
  35. package/runtime/engine.d.ts.map +1 -1
  36. package/runtime/grants.d.ts +1 -0
  37. package/runtime/grants.d.ts.map +1 -1
  38. package/runtime/grants.js +5 -2
  39. package/runtime/hotkeys.d.ts.map +1 -1
  40. package/runtime/hotkeys.js +7 -4
  41. package/runtime/installer.d.ts +10 -6
  42. package/runtime/installer.d.ts.map +1 -1
  43. package/runtime/installer.js +106 -48
  44. package/runtime/lifecycle.d.ts +2 -2
  45. package/runtime/lifecycle.d.ts.map +1 -1
  46. package/runtime/lifecycle.js +144 -42
  47. package/runtime/pages.d.ts +3 -1
  48. package/runtime/pages.d.ts.map +1 -1
  49. package/runtime/pages.js +48 -14
  50. package/runtime/parts.d.ts +1 -0
  51. package/runtime/parts.d.ts.map +1 -1
  52. package/runtime/parts.js +6 -3
  53. package/runtime/programs.d.ts +1 -0
  54. package/runtime/programs.d.ts.map +1 -1
  55. package/runtime/programs.js +4 -1
  56. package/runtime/sessions.d.ts +10 -3
  57. package/runtime/sessions.d.ts.map +1 -1
  58. package/runtime/sessions.js +104 -59
  59. package/runtime/toaster.d.ts.map +1 -1
  60. package/runtime/toaster.js +23 -9
  61. package/runtime/ui.d.ts +22 -4
  62. package/runtime/ui.d.ts.map +1 -1
  63. package/runtime/ui.js +161 -59
  64. package/runtime/watches.d.ts.map +1 -1
  65. package/runtime/watches.js +18 -4
  66. package/runtime/widgets.d.ts +7 -5
  67. package/runtime/widgets.d.ts.map +1 -1
  68. package/runtime/widgets.js +31 -21
  69. package/session/context/turn/message/attached-file.d.ts +13 -0
  70. package/session/context/turn/message/attached-file.d.ts.map +1 -0
  71. package/session/context/turn/message/attached-file.js +21 -0
  72. package/session/context/turn/message/message.d.ts +3 -2
  73. package/session/context/turn/message/message.d.ts.map +1 -1
  74. package/session/context/turn/message/message.js +20 -6
  75. package/session/conversation.d.ts +15 -3
  76. package/session/conversation.d.ts.map +1 -1
  77. package/session/conversation.js +23 -4
  78. package/session/goal/task.d.ts +5 -0
  79. package/session/goal/task.d.ts.map +1 -1
  80. package/session/session.d.ts +36 -7
  81. package/session/session.d.ts.map +1 -1
  82. package/session/session.js +2 -1
  83. package/src/installer.ts +3 -2
  84. package/src/jobs/program.ts +41 -21
  85. package/src/mod.ts +3 -2
  86. package/src/records.ts +49 -1
  87. package/src/register.ts +11 -8
  88. package/src/runtime/before.ts +61 -22
  89. package/src/runtime/buttons.ts +22 -0
  90. package/src/runtime/conversations.ts +170 -34
  91. package/src/runtime/engine.ts +2 -0
  92. package/src/runtime/grants.ts +6 -2
  93. package/src/runtime/hotkeys.ts +7 -4
  94. package/src/runtime/installer.ts +147 -83
  95. package/src/runtime/lifecycle.ts +149 -48
  96. package/src/runtime/pages.ts +56 -12
  97. package/src/runtime/parts.ts +8 -4
  98. package/src/runtime/programs.ts +5 -1
  99. package/src/runtime/sessions.ts +106 -59
  100. package/src/runtime/toaster.ts +27 -9
  101. package/src/runtime/ui.ts +166 -62
  102. package/src/runtime/watches.ts +20 -5
  103. package/src/runtime/widgets.ts +35 -23
  104. package/src/session/context/turn/message/attached-file.ts +36 -0
  105. package/src/session/context/turn/message/message.ts +20 -7
  106. package/src/session/conversation.ts +31 -6
  107. package/src/session/goal/task.ts +6 -0
  108. package/src/session/session.ts +18 -7
  109. package/src/testing/fake-claude.ts +21 -6
  110. package/src/testing/fake-elements.ts +57 -8
  111. package/src/testing/fake-sessions.ts +72 -24
  112. package/src/testing.ts +114 -33
  113. package/src/ui/areas.ts +1 -0
  114. package/src/ui/components.ts +14 -8
  115. package/src/ui/elements.ts +6 -2
  116. package/src/ui/markdown.ts +3 -3
  117. package/src/ui/panes.ts +2 -2
  118. package/src/ui/styles.ts +6 -2
  119. package/testing/fake-claude.d.ts +3 -0
  120. package/testing/fake-claude.d.ts.map +1 -1
  121. package/testing/fake-claude.js +18 -5
  122. package/testing/fake-elements.d.ts.map +1 -1
  123. package/testing/fake-elements.js +52 -7
  124. package/testing/fake-sessions.d.ts +24 -10
  125. package/testing/fake-sessions.d.ts.map +1 -1
  126. package/testing/fake-sessions.js +66 -20
  127. package/testing.d.ts +8 -12
  128. package/testing.d.ts.map +1 -1
  129. package/testing.js +113 -27
  130. package/ui/areas.d.ts +4 -0
  131. package/ui/areas.d.ts.map +1 -1
  132. package/ui/components.d.ts +1 -0
  133. package/ui/components.d.ts.map +1 -1
  134. package/ui/components.js +11 -7
  135. package/ui/elements.d.ts +3 -0
  136. package/ui/elements.d.ts.map +1 -1
  137. package/ui/elements.js +6 -1
  138. package/ui/markdown.d.ts +2 -2
  139. package/ui/markdown.d.ts.map +1 -1
  140. package/ui/panes.d.ts +2 -1
  141. package/ui/panes.d.ts.map +1 -1
  142. package/ui/styles.d.ts +11 -2
  143. package/ui/styles.d.ts.map +1 -1
@@ -4,6 +4,8 @@ type Drawn = { readonly type: string; readonly props: Record<string, unknown>; r
4
4
 
5
5
  const boxInsides = new WeakMap<object, Drawn>()
6
6
 
7
+ const measuredProps = ['width', 'height', 'minWidth', 'minHeight'] as const
8
+
7
9
  function element(type: string) {
8
10
  return (props: Record<string, unknown>): RenderElement => {
9
11
  const { children, ...rest } = props
@@ -11,13 +13,17 @@ function element(type: string) {
11
13
  const keyed = type === 'Button' ? { key: rest['label'] ?? flat.map(textOf).join(''), ...rest } : rest
12
14
  const drawn: Drawn = { type, props: keyed, ...(flat.length === 0 ? {} : { children: flat }) }
13
15
  if (type !== 'Box') return drawn as unknown as RenderElement
16
+ for (const size of measuredProps) {
17
+ const value = rest[size]
18
+ if (typeof value === 'number' && !(Number.isFinite(value) && value >= 0 && value <= 10000)) throw new Error(`Box prop "${size}" must be a finite number between 0 and 10000`)
19
+ }
14
20
  const box = Object.freeze({})
15
21
  boxInsides.set(box, drawn)
16
22
  return box as unknown as RenderElement
17
23
  }
18
24
  }
19
25
 
20
- export const elements = {
26
+ export const elements: ElementTable = {
21
27
  Box: element('Box'),
22
28
  Text: element('Text'),
23
29
  Button: element('Button'),
@@ -27,7 +33,10 @@ export const elements = {
27
33
  Input: element('Input'),
28
34
  Select: element('Select'),
29
35
  Image: element('Image'),
30
- } as unknown as ElementTable
36
+ Svg: element('Svg'),
37
+ Raster: element('Raster'),
38
+ Client: element('Client'),
39
+ }
31
40
 
32
41
  export function findElement(node: RenderNode, type: string, key: string): Drawn['props'] | undefined {
33
42
  if (typeof node === 'string') return undefined
@@ -41,11 +50,12 @@ export function findElement(node: RenderNode, type: string, key: string): Drawn[
41
50
  }
42
51
 
43
52
  export function rowsOf(node: RenderNode, width: number): string[] {
44
- if (typeof node !== 'string' && insideOf(node).type === 'Box') return boxRows(insideOf(node), width)
45
- const wrap = typeof node === 'string' ? undefined : insideOf(node).props['wrap']
53
+ const drawn = typeof node === 'string' ? undefined : insideOf(node)
54
+ if (drawn?.type === 'Box') return boxRows(drawn, width)
55
+ if (drawn?.type === 'Svg') return []
46
56
  return textOf(node)
47
57
  .split('\n')
48
- .flatMap((line) => fitted(line, width, wrap))
58
+ .flatMap((line) => fitted(line, width, drawn?.props['wrap']))
49
59
  }
50
60
 
51
61
  export function textOf(node: RenderNode): string {
@@ -89,11 +99,50 @@ function boxRows({ props, children = [] }: Drawn, width: number): string[] {
89
99
  const size = (key: string) => (typeof props[key] === 'number' ? props[key] : undefined)
90
100
  const space = (side: string, axis: string) => (size(`padding${side}`) ?? size(`padding${axis}`) ?? size('padding') ?? 0) + (size(`margin${side}`) ?? size(`margin${axis}`) ?? size('margin') ?? 0)
91
101
  const left = space('Left', 'X')
102
+ const right = space('Right', 'X')
103
+ const border = typeof props['borderStyle'] === 'string' ? 1 : 0
92
104
  const direction = String(props['flexDirection'] ?? 'row')
93
- const ordered = direction.endsWith('-reverse') ? [...children].reverse() : children
94
- const inner = Math.min(width, size('width') ?? width) - left - space('Right', 'X')
105
+ const flowing = children.filter((child) => !isPlaced(child))
106
+ const ordered = direction.endsWith('-reverse') ? [...flowing].reverse() : flowing
107
+ const outer = Math.min(width, size('width') ?? width)
108
+ const inner = outer - left - right - border * 2
95
109
  const rows = direction.startsWith('column') ? stacked(ordered, inner, size('rowGap') ?? size('gap') ?? 0) : sideBySide(ordered, inner, size('columnGap') ?? size('gap') ?? 0, props['justifyContent'] === 'space-between')
96
- return [...blankRows(space('Top', 'Y')), ...rows.map((row) => paddedTo(`${' '.repeat(left)}${row}`, size('minWidth') ?? 0)), ...blankRows(space('Bottom', 'Y'))]
110
+ const content = border === 1 && Number.isFinite(inner) ? inner : right === 0 && border === 0 ? 0 : Math.max(widthOf(rows), (size('minWidth') ?? 0) - left - right - border * 2)
111
+ const padded = [...blankRows(space('Top', 'Y')), ...rows.map((row) => `${' '.repeat(left)}${paddedTo(row, content)}${' '.repeat(right)}`), ...blankRows(space('Bottom', 'Y'))]
112
+ const framed = border === 0 ? padded : framedRows(padded, left + content + right, props['borderStyle'])
113
+ return placedOver(
114
+ framed.map((row) => paddedTo(row, size('minWidth') ?? 0)),
115
+ children.filter(isPlaced),
116
+ outer,
117
+ )
118
+ }
119
+
120
+ function isPlaced(node: RenderNode): node is RenderElement {
121
+ if (typeof node === 'string') return false
122
+ const { props } = insideOf(node)
123
+ return props['position'] === 'absolute' && typeof props['top'] === 'number'
124
+ }
125
+
126
+ function framedRows(rows: readonly string[], columns: number, style: unknown): string[] {
127
+ const [topLeft, topRight, bottomLeft, bottomRight] = style === 'round' ? ['╭', '╮', '╰', '╯'] : ['┌', '┐', '└', '┘']
128
+ return [`${topLeft}${'─'.repeat(columns)}${topRight}`, ...rows.map((row) => `│${paddedTo(row, columns)}│`), `${bottomLeft}${'─'.repeat(columns)}${bottomRight}`]
129
+ }
130
+
131
+ function placedOver(rows: readonly string[], placed: readonly RenderElement[], width: number): string[] {
132
+ const drawn = [...rows]
133
+ for (const node of placed) {
134
+ const { props } = insideOf(node)
135
+ const top = Number(props['top'])
136
+ const left = typeof props['left'] === 'number' ? props['left'] : 0
137
+ rowsOf(node, width - left).forEach((line, index) => {
138
+ const row = top + index
139
+ if (row < 0 || row >= drawn.length) return
140
+ const cells = [...paddedTo(drawn[row] ?? '', left)]
141
+ cells.splice(left, [...line].length, ...line)
142
+ drawn[row] = cells.join('')
143
+ })
144
+ }
145
+ return drawn
97
146
  }
98
147
 
99
148
  function stacked(children: readonly RenderNode[], width: number, gap: number): string[] {
@@ -1,4 +1,5 @@
1
1
  import { isObject } from '../records.js'
2
+ import { AttachedFile, attachmentNameOf, type AttachmentType } from '../session/context/turn/message/attached-file.js'
2
3
  import { InstructionFile } from '../session/context/turn/message/instruction-file.js'
3
4
  import { Message, messagesOf, noFiles, searchText, skillPrefix, type Row } from '../session/context/turn/message/message.js'
4
5
  import { Note } from '../session/context/turn/message/note.js'
@@ -7,14 +8,14 @@ import { Prompt } from '../session/context/turn/message/prompt.js'
7
8
  import { Reply } from '../session/context/turn/message/reply.js'
8
9
  import { Mod, Person, Schedule, type Sender } from '../session/context/turn/message/sender.js'
9
10
  import { GoalChange } from '../session/goal/goal.js'
10
- import { Task } from '../session/goal/task.js'
11
+ import { Task, TaskStart } from '../session/goal/task.js'
11
12
  import { Skill } from '../session/context/turn/message/skill.js'
12
13
  import { ToolCall } from '../session/context/turn/message/tool-call.js'
13
14
  import { ToolResult } from '../session/context/turn/message/tool-result.js'
14
- import { createConversation, emptyConversation, searchLimit, type ContextData, type Conversation, type SessionChange, type SessionData, type TaskData, type TurnData, type TurnEnding } from '../session/conversation.js'
15
- import type { Place } from '../runtime/before.js'
15
+ import { createConversation, emptyConversation, searchLimit, tasksWithAgents, type ContextData, type Conversation, type SessionChange, type SessionData, type TaskData, type TurnData, type TurnEnding } from '../session/conversation.js'
16
+ import type { Decide, Place } from '../runtime/before.js'
16
17
  import type { Claude } from '../runtime/claude.js'
17
- import { conversationMethods, everySession, isHeardBy, resultOf, type Listened } from '../runtime/sessions.js'
18
+ import { conversationMethods, everySession, isHeardBy, resultOf, type Listened, type SessionColor } from '../runtime/sessions.js'
18
19
  import { receivedMatcher } from '../utils/matcher.js'
19
20
  import { firstLine, messageOf } from '../utils/text.js'
20
21
 
@@ -26,6 +27,7 @@ export type MessageDraft =
26
27
  | { readonly type: typeof ToolResult; readonly callId: string; readonly output: string; readonly isError?: boolean }
27
28
  | { readonly type: typeof Skill; readonly name: string; readonly text: string }
28
29
  | { readonly type: typeof InstructionFile; readonly path: string; readonly text: string }
30
+ | { readonly type: typeof AttachedFile; readonly path: string; readonly attachmentType: AttachmentType; readonly text: string }
29
31
  | { readonly type: typeof Notice; readonly text: string }
30
32
 
31
33
  export type FakeSession = Partial<Omit<SessionData, 'id'>> & {
@@ -45,6 +47,8 @@ type Kept = {
45
47
 
46
48
  type Heard = { readonly event: Listened['event']; readonly matcher: unknown }
47
49
 
50
+ export type StartedTask = { readonly title: string; readonly agentType?: string; readonly model?: string; readonly openedBy?: string; readonly parent?: string; readonly isBackground?: true; readonly forkedFrom?: string }
51
+
48
52
 
49
53
  export type FakeSessions = {
50
54
  answer(caller: string, method: string, input: unknown): unknown
@@ -52,15 +56,17 @@ export type FakeSessions = {
52
56
  draft(draft: MessageDraft, task?: string): Message
53
57
  place(): Place
54
58
  wants(mod: string, event: string): boolean
55
- endTurn(ending?: TurnEnding): void
59
+ endTurn(ending?: TurnEnding, task?: string): void
56
60
  compact(): void
57
- startTask(task: { readonly title: string; readonly agentType?: string; readonly model?: string; readonly openedBy?: string; readonly parent?: string }): string
61
+ startTask(task: StartedTask): string
58
62
  start(id: string): void
63
+ color(): SessionColor
64
+ recolor(color: string | undefined): SessionColor
59
65
  lastTurn(): TurnData | undefined
60
66
  readonly sent: { readonly session: string; readonly task?: string; readonly type: string; readonly text: string; readonly as?: string }[]
61
67
  }
62
68
 
63
- export function fakeSessions({ project, cwd, given, push, callTool }: { readonly project: () => string; readonly cwd: () => string; readonly given: readonly FakeSession[]; readonly push: (mod: string, session: string, change: SessionChange) => void; readonly callTool: Claude['tool']['call'] }): FakeSessions {
69
+ export function fakeSessions({ project, cwd, given, push, callTool, listAgents, decide }: { readonly project: () => string; readonly cwd: () => string; readonly given: readonly FakeSession[]; readonly push: (mod: string, session: string, change: SessionChange) => void; readonly callTool: Claude['tool']['call']; readonly listAgents: Claude['agent']['list']; readonly decide: Decide }): FakeSessions {
64
70
  const kept = new Map<string, Kept>()
65
71
  const heard = new Map<string, Map<string, readonly Heard[]>>()
66
72
  const watches = new Map<string, Map<string, readonly string[]>>()
@@ -96,6 +102,14 @@ export function fakeSessions({ project, cwd, given, push, callTool }: { readonly
96
102
  return session.messages.slice(before)
97
103
  }
98
104
 
105
+ const openTask = (session: Kept, { title, agentType, model, openedBy = '', parent, isBackground, forkedFrom }: StartedTask): TaskData => {
106
+ const owner = forkedFrom !== undefined ? { forkedFrom } : isBackground === true ? (agentType === undefined ? {} : { agentType }) : { agentType: agentType ?? 'general-purpose' }
107
+ const data: TaskData = { id: `task-${(ids += 1)}`, title, ...owner, openedBy, openedAt: now(), ...(model === undefined ? {} : { model }), ...(parent === undefined ? {} : { parent }), ...(isBackground === true ? { isBackground } : {}) }
108
+ if (isBackground === true) keep({ id: data.id, title, project: session.data.project, cwd: session.data.cwd, model: model ?? session.data.model, startedAt: data.openedAt, lastAt: data.openedAt, isRunning: true, ...owner })
109
+ record(session, session.conversation.startTask(data))
110
+ return data
111
+ }
112
+
99
113
  for (const each of given) {
100
114
  const session = keep({ title: '', project: project(), cwd: project(), model: 'test-model', startedAt: now(), lastAt: '', isRunning: false, ...each })
101
115
  for (const draft of each.messages ?? []) add(session, draft)
@@ -110,11 +124,15 @@ export function fakeSessions({ project, cwd, given, push, callTool }: { readonly
110
124
 
111
125
  const currentSession = () => sessionOf(current)
112
126
 
127
+ const colorOf = ({ data: { id, color } }: Kept): SessionColor => ({ session: id, ...(color === undefined ? {} : { color }) })
128
+
113
129
  const place = (): Place => {
114
130
  const { session, context, contextOpenedAt, turn, tasks, taskTurns, openCalls } = currentSession().conversation.state
115
131
  return { session, context, contextOpenedAt, ...(turn === undefined ? {} : { turn }), tasks, taskTurns, ...(openCalls === undefined ? {} : { openCalls }) }
116
132
  }
117
133
 
134
+ const wantsBefore = (mod: string, event: string) => [current, everySession].some((target) => (watches.get(mod)?.get(target) ?? []).includes(event))
135
+
118
136
  const taskOf = (session: Kept, id: string) => {
119
137
  const task = session.tasks.get(id)
120
138
  if (task === undefined) throw new Error(`Session ${session.data.id} has no task ${id}. Start it with tested.startTask.`)
@@ -172,7 +190,11 @@ export function fakeSessions({ project, cwd, given, push, callTool }: { readonly
172
190
  const task = taskOf(session, request['task'])
173
191
  return [{ number: session.conversation.state.tasks[task.id]?.context ?? 1, openedAt: task.openedAt, ...(task.closedAt === undefined ? {} : { closedAt: task.closedAt }) }]
174
192
  },
175
- [conversationMethods.tasks]: (request) => [...sessionOf(String(request['session'])).tasks.values()].filter((task) => request['all'] === true || task.parent === request['task']),
193
+ async [conversationMethods.tasks](request) {
194
+ const session = String(request['session'])
195
+ const tasks = [...sessionOf(session).tasks.values()].filter((task) => request['all'] === true || task.parent === request['task'])
196
+ return session === current ? tasksWithAgents(tasks, await listAgents()) : tasks
197
+ },
176
198
  [conversationMethods.goal](request) {
177
199
  const session = sessionOf(String(request['session']))
178
200
  const task = request['task']
@@ -196,7 +218,7 @@ export function fakeSessions({ project, cwd, given, push, callTool }: { readonly
196
218
  watches.set(caller, targets)
197
219
  return null
198
220
  },
199
- async [conversationMethods.act](request) {
221
+ async [conversationMethods.act](request, caller) {
200
222
  const session = sessionOf(String(request['session']))
201
223
  const action = String(request['action'])
202
224
  const input = isObject(request['input']) ? request['input'] : {}
@@ -214,11 +236,24 @@ export function fakeSessions({ project, cwd, given, push, callTool }: { readonly
214
236
  sent.push({ session: session.data.id, ...(task === undefined ? {} : { task }), type: String(input['type']), text: String(input['text']), ...(typeof input['as'] === 'string' ? { as: input['as'] } : {}) })
215
237
  return null
216
238
  }
217
- if (action === 'startTask') {
218
- const id = `task-${(ids += 1)}`
219
- const data: TaskData = { id, title: typeof input['title'] === 'string' ? input['title'] : '', agentType: typeof input['agentType'] === 'string' ? input['agentType'] : 'general-purpose', openedBy: '', openedAt: now(), ...(typeof input['model'] === 'string' ? { model: input['model'] } : {}) }
220
- record(session, session.conversation.startTask(data))
221
- return data
239
+ const text = (key: string) => (typeof input[key] === 'string' ? input[key] : undefined)
240
+ const title = text('title') ?? ''
241
+ if (action === 'startTask' || action === 'fork' || action === 'startBackgroundTask' || action === 'clone') {
242
+ if (title.trim() === '') throw new Error('a task needs a title.')
243
+ const isClone = action === 'clone'
244
+ const isBackground = isClone || action === 'startBackgroundTask'
245
+ const forkedFrom = isClone || action === 'fork' ? session.data.id : undefined
246
+ const agentType = forkedFrom === undefined ? text('agentType') : undefined
247
+ const model = forkedFrom === undefined ? text('model') : undefined
248
+ const fresh = { ...(agentType === undefined ? {} : { agentType }), ...(model === undefined ? {} : { model }), ...(forkedFrom === undefined ? {} : { forkedFrom }) }
249
+ if (!isBackground) return openTask(session, { title, ...fresh })
250
+ if (input['name'] !== undefined) throw new Error('a background task takes no name.')
251
+ const asked: TaskData = { id: '', title, ...fresh, openedBy: '', openedAt: now(), isBackground: true }
252
+ const answer = wantsBefore(caller, TaskStart) ? await decide(TaskStart, { type: TaskStart, task: { ...asked, context: session.conversation.state.context } }) : undefined
253
+ if (answer?.refuse !== undefined) throw new Error(`a beforeTaskStart listener refused the task: ${answer.refuse}`)
254
+ const owner = forkedFrom === undefined ? (answer?.owner ?? {}) : {}
255
+ const chosen = { ...(owner.agentType === undefined ? {} : { agentType: owner.agentType }), ...(owner.model === undefined ? {} : { model: owner.model }) }
256
+ return openTask(session, { ...fresh, ...chosen, title: answer?.title ?? title, isBackground: true })
222
257
  }
223
258
  if (session.data.id === current) return resultOf(place(), { name: String(input['name']), input: input['input'] as Readonly<Record<string, unknown>>, cwd: project() }, await callTool({ ...input['input'] as object, tool: String(input['name']) } as never))
224
259
  return { id: '', type: ToolResult, at: now(), session: session.data.id, context: session.conversation.state.context, callId: '', name: '', input: {}, cwd: project(), files: noFiles, output: '', isError: false }
@@ -242,7 +277,10 @@ export function fakeSessions({ project, cwd, given, push, callTool }: { readonly
242
277
  return { deny: messageOf(error) }
243
278
  }
244
279
  },
245
- add: (draft, task) => add(currentSession(), draft, task),
280
+ add(draft, task) {
281
+ const session = currentSession()
282
+ return task !== undefined && session.tasks.get(task)?.isBackground === true ? add(sessionOf(task), draft) : add(session, draft, task)
283
+ },
246
284
  draft(draft, task) {
247
285
  const where = place()
248
286
  const [unplaced] = messagesOf(rowOf(draft, `message-${(ids += 1)}`, now(), cwd()), task !== undefined)
@@ -252,27 +290,36 @@ export function fakeSessions({ project, cwd, given, push, callTool }: { readonly
252
290
  return { ...unplaced, ...call, session: where.session, context: task === undefined ? where.context : (where.tasks[task]?.context ?? where.context), ...(turn === undefined ? {} : { turn: turn.prompt.id }), ...(task === undefined ? {} : { task }) }
253
291
  },
254
292
  place,
255
- wants: (mod, event) => [current, everySession].some((target) => (watches.get(mod)?.get(target) ?? []).includes(event)),
256
- endTurn(ending = {}) {
293
+ wants: wantsBefore,
294
+ endTurn(ending = {}, task) {
257
295
  const session = currentSession()
258
- record(session, session.conversation.endTurn(now(), { reason: 'answer', ...ending }))
296
+ const background = task === undefined ? undefined : session.tasks.get(task)
297
+ if (background?.isBackground !== true) {
298
+ record(session, session.conversation.endTurn(now(), { reason: 'answer', ...ending }, task))
299
+ return
300
+ }
301
+ const owner = sessionOf(background.id)
302
+ record(owner, owner.conversation.endTurn(now(), { reason: 'answer', ...ending }))
303
+ record(session, session.conversation.endTask(background, now()))
259
304
  },
260
305
  compact() {
261
306
  const session = currentSession()
262
307
  record(session, session.conversation.add({ id: `compact-${(ids += 1)}`, at: now(), type: 'system', name: 'compact_boundary', isMeta: false, isSummary: false, content: [] }))
263
308
  },
264
- startTask({ title, agentType = 'general-purpose', model, openedBy = '', parent }) {
265
- const session = currentSession()
266
- const id = `task-${(ids += 1)}`
267
- record(session, session.conversation.startTask({ id, title, agentType, openedBy, openedAt: now(), ...(model === undefined ? {} : { model }), ...(parent === undefined ? {} : { parent }) }))
268
- return id
269
- },
309
+ startTask: (task) => openTask(currentSession(), task).id,
270
310
  start(id) {
271
311
  current = id
272
312
  const session = kept.get(id) ?? keep({ id, title: '', project: project(), cwd: project(), model: 'test-model', startedAt: now(), lastAt: now(), isRunning: true })
273
313
  session.data = { ...session.data, isRunning: true }
274
314
  if (session.conversation.state.context === 0) record(session, session.conversation.startContext(now()))
275
315
  },
316
+ color: () => colorOf(currentSession()),
317
+ recolor(color) {
318
+ const session = currentSession()
319
+ const { color: _color, ...data } = session.data
320
+ session.data = color === undefined ? data : { ...data, color }
321
+ return colorOf(session)
322
+ },
276
323
  lastTurn: () => [...currentSession().turns.values()].filter((turn) => turn.prompt.task === undefined).at(-1),
277
324
  sent,
278
325
  }
@@ -289,5 +336,6 @@ function rowOf(draft: MessageDraft, id: string, at: string, cwd: string): Row {
289
336
  if (draft.type === ToolResult) return { ...base, type: 'user', content: [{ type: 'tool_result', tool_use_id: draft.callId, content: draft.output, ...(draft.isError === true ? { is_error: true } : {}) }] }
290
337
  if (draft.type === Skill) return { ...base, type: 'user', isMeta: true, content: text(`${skillPrefix} /skills/${draft.name}\n\n${draft.text}`) }
291
338
  if (draft.type === InstructionFile) return { ...base, type: 'attachment', name: 'nested_memory', content: text(draft.text), path: draft.path }
339
+ if (draft.type === AttachedFile) return { ...base, type: 'attachment', name: attachmentNameOf(draft.attachmentType), content: text(draft.text), path: draft.path }
292
340
  return { ...base, type: 'system', name: 'informational', content: text(draft.text) }
293
341
  }