@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
@@ -7,7 +7,7 @@ import { Person, senderOf } from '../session/context/turn/message/sender.js'
7
7
  import { ToolCall } from '../session/context/turn/message/tool-call.js'
8
8
  import { ToolResult } from '../session/context/turn/message/tool-result.js'
9
9
  import { TurnEnd, TurnStart, type Turn } from '../session/context/turn/turn.js'
10
- import { searchLimit, type ContextData, type GoalData, type SessionChange, type SessionData, type TaskData, type TurnData } from '../session/conversation.js'
10
+ import { forkAgentType, searchLimit, type ContextData, type GoalData, type SessionChange, type SessionData, type TaskData, type TurnData } from '../session/conversation.js'
11
11
  import { GoalChange, type Goal } from '../session/goal/goal.js'
12
12
  import { TaskEnd, TaskStart, type Task } from '../session/goal/task.js'
13
13
  import { beforeEvents, processEvents, Session, sessionEvents, type BeforeEvent, type Placement, type BeforeMethods, type Surface, type OnMethods, type ProcessBeforeMethods, type ProcessName, type ProcessOnMethods, type RecordPart, type SearchOptions, type SessionEvent, type SessionScope, type SessionSend, type Sessions, type TaskOwner, type TaskStartArgs, type ToolCallArgs, type Usage } from '../session/session.js'
@@ -41,9 +41,9 @@ export const conversationMethods = {
41
41
  before: 'cmod:before',
42
42
  } as const
43
43
 
44
- export type Act = 'send' | 'startTask' | 'callTool' | 'usage' | 'surfaces' | 'abort'
44
+ export type Act = 'send' | 'startTask' | 'fork' | 'clone' | 'startBackgroundTask' | 'callTool' | 'usage' | 'surfaces' | 'abort'
45
45
 
46
- const actPermissions: Readonly<Record<Exclude<Act, 'callTool'>, string>> = { send: 'prompt', startTask: 'tasks', usage: 'conversation', surfaces: 'conversation', abort: 'prompt' }
46
+ const actPermissions: Readonly<Record<Exclude<Act, 'callTool'>, string>> = { send: 'prompt', startTask: 'tasks', fork: 'tasks', clone: 'tasks', startBackgroundTask: 'tasks', usage: 'conversation', surfaces: 'conversation', abort: 'prompt' }
47
47
 
48
48
  const startsTurns: readonly string[] = ['CronCreate']
49
49
 
@@ -51,6 +51,11 @@ const ownSchedule: readonly string[] = ['CronDelete', 'CronList']
51
51
 
52
52
  export const beforePermission = (event: BeforeEvent): string => (event === 'toolCall' ? 'tools' : event === TaskStart ? 'tasks' : 'prompt')
53
53
 
54
+ const answerPermissions = (event: BeforeEvent, answer: Partial<Answers>): string[] => [
55
+ ...(Object.keys(answer).some((key) => key !== 'note') ? [beforePermission(event)] : []),
56
+ ...(answer.note === undefined ? [] : ['prompt']),
57
+ ]
58
+
54
59
  export const isAct = (action: string): action is Act => action === 'callTool' || action in actPermissions
55
60
 
56
61
  export function toolPermission(tool: string): string | undefined {
@@ -82,7 +87,8 @@ type Owned = { readonly session: string; readonly task: string }
82
87
  export async function usageOf(claude: Pick<Claude, 'session'>): Promise<Usage> {
83
88
  const [model, usage] = await Promise.all([claude.session.model(), claude.session.usage()])
84
89
  const { tokens, window, percent } = usage.context
85
- return { model, contextWindow: { ...(tokens === undefined ? {} : { tokenCount: tokens }), size: window, ...(percent === undefined ? {} : { percent }) }, ...(usage.cost === undefined ? {} : { cost: { usd: usage.cost.usd } }) }
90
+ const rateLimits = usage.rateLimits.map(({ kind, percentUsed, resetsAt }) => ({ type: kind, percent: percentUsed, ...(resetsAt === undefined ? {} : { resetsAt }) }))
91
+ return { model, contextWindow: { ...(tokens === undefined ? {} : { tokenCount: tokens }), size: window, ...(percent === undefined ? {} : { percent }) }, rateLimits, ...(usage.cost === undefined ? {} : { cost: { usd: usage.cost.usd } }) }
86
92
  }
87
93
 
88
94
  export function payloadOf(change: SessionChange): unknown {
@@ -90,8 +96,9 @@ export function payloadOf(change: SessionChange): unknown {
90
96
  if ('turn' in change) return change.turn
91
97
  if ('context' in change) return change.context
92
98
  if ('task' in change) {
93
- const { id, title, agentType, model, openedBy, openedAt, closedAt } = change.task
94
- return { id, title, openedBy, openedAt, ...(closedAt === undefined ? {} : { closedAt }), owner: { id, title, agentType, ...(model === undefined ? {} : { model }), startedAt: openedAt } }
99
+ const { id, title, agentType, model, openedBy, openedAt, closedAt, isBackground, forkedFrom } = change.task
100
+ const owner = { id, title, ...(agentType === undefined ? {} : { agentType }), ...(model === undefined ? {} : { model }), ...(forkedFrom === undefined ? {} : { forkedFrom }), startedAt: openedAt }
101
+ return { id, title, openedBy, openedAt, ...(closedAt === undefined ? {} : { closedAt }), ...(isBackground === true ? { isBackground } : {}), owner }
95
102
  }
96
103
  if ('usage' in change) return change.usage
97
104
  return change.goal
@@ -224,8 +231,10 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
224
231
  if (!decides(entry, session, latest)) return undefined
225
232
  const payload = await payloadFor(session, latest)
226
233
  const answer = await invoke(entry.method, entry.options, async (time) => takenAnswer(entry.method, beforeAnswerKeys[event], await entry.listener(payload as never, time)), beforeAnswerKeys[event])
227
- if (answer === undefined || answer === null || (typeof answer === 'object' && 'refuse' in answer) || isGranted(beforePermission(event))) return answer
228
- throw new Error(`Its ${entry.method} answer changes nothing until you grant ${name} "${permissionWords(beforePermission(event))}". ${grantFix}`)
234
+ if (answer === undefined || answer === null || (typeof answer === 'object' && 'refuse' in answer)) return answer
235
+ const missing = answerPermissions(event, answer as Partial<Answers>).find((permission) => !isGranted(permission))
236
+ if (missing === undefined) return answer
237
+ throw new Error(`Its ${entry.method} answer changes nothing until you grant ${name} "${permissionWords(missing)}". ${grantFix}`)
229
238
  })
230
239
  const answer = await foldBefore(event, change, askers, (error) => report(`session:${methodOf('before', event)}`, `The ${methodOf('before', event)} listener failed`, error))
231
240
  return answer?.note === undefined ? answer : { ...answer, note: modNote(name, answer.note) }
@@ -293,17 +302,25 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
293
302
  }
294
303
  }
295
304
 
296
- const taskOf = (session: string, data: TaskData): Task => ({
297
- id: data.id,
298
- title: data.title,
299
- openedBy: data.openedBy,
300
- openedAt: data.openedAt,
301
- ...(data.closedAt === undefined ? {} : { closedAt: data.closedAt }),
302
- owner: ownerOf(session, data),
303
- ...recordPart({ session, task: data.id }),
304
- tasks: async () => (await call<TaskData[]>(conversationMethods.tasks, { session, task: data.id })).map((task) => taskOf(session, task)),
305
- messages: () => messagesFrom(conversationMethods.messages, { session, task: data.id }),
306
- })
305
+ const taskOf = (session: string, data: TaskData): Task => {
306
+ const record = data.isBackground === true ? { session: data.id } : { session, task: data.id }
307
+ return {
308
+ id: data.id,
309
+ title: data.title,
310
+ openedBy: data.openedBy,
311
+ openedAt: data.openedAt,
312
+ ...(data.closedAt === undefined ? {} : { closedAt: data.closedAt }),
313
+ ...(data.status === undefined ? {} : { status: data.status }),
314
+ ...(data.name === undefined ? {} : { name: data.name }),
315
+ ...(data.spawnedBy === undefined ? {} : { spawnedBy: data.spawnedBy }),
316
+ ...(data.isBackground === true ? { isBackground: true } : {}),
317
+ owner: ownerOf(session, data),
318
+ ...recordPart({ session, task: data.id }),
319
+ search: (text: string, matcher?: unknown) => searched({ text, ...record }, matcher),
320
+ tasks: async () => (await call<TaskData[]>(conversationMethods.tasks, record)).map((task) => taskOf(record.session, task)),
321
+ messages: () => messagesFrom(conversationMethods.messages, record),
322
+ }
323
+ }
307
324
 
308
325
  const goalOf = (where: () => Scope): Goal =>
309
326
  ({
@@ -345,11 +362,9 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
345
362
  if ('drop' in submitted && typeof submitted.drop === 'string') throw new Error(`${name}: Claude Code dropped the prompt: ${submitted.drop}`)
346
363
  }
347
364
 
348
- const startHere = async (task: TaskStartArgs): Promise<Task> => taskOf(current.id, await spawnTask(claude, task).catch((error: unknown) => Promise.reject(new Error(`${name}: ${messageOf(error)}`))))
349
-
350
365
  const surfacesHere = async (): Promise<Surface[]> => [...(await claude.session.surfaces())]
351
366
 
352
- const sessionOf = (read: () => SessionData, owned?: Owned) => {
367
+ const sessionOf = (read: () => SessionData, owned?: Owned, isOwner = owned !== undefined) => {
353
368
  const id = () => read().id
354
369
  const isHere = () => owned === undefined && id() === current.id
355
370
  const scope = (): Scope => (owned === undefined ? { session: id(), isAgent: true } : { session: owned.session, task: owned.task, isAgent: true })
@@ -411,32 +426,34 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
411
426
  await act(id(), 'send', message, owned)
412
427
  },
413
428
  }
414
- if (owned !== undefined) return session as unknown as Sessions['current']
429
+ if (isOwner) return session as unknown as Sessions['current']
430
+ const started = async (starting: Promise<TaskData>): Promise<Task> => taskOf(id(), await starting.catch((error: unknown) => Promise.reject(new Error(`${name}: ${messageOf(error)}`))))
415
431
  return Object.assign(session, {
416
- async startTask(task: TaskStartArgs): Promise<Task> {
417
- if (isHere()) return startHere(task)
418
- const started = await act<TaskData>(id(), 'startTask', task)
419
- return taskOf(id(), started)
420
- },
432
+ startTask: (task: TaskStartArgs): Promise<Task> => started(task.isBackground === true ? act<TaskData>(id(), 'startBackgroundTask', task) : isHere() ? spawnTask(claude, task) : act<TaskData>(id(), 'startTask', task)),
433
+ fork: (task: Pick<TaskStartArgs, 'prompt' | 'title'>): Promise<Task> => started(isHere() ? spawnTask(claude, task, current.id) : act<TaskData>(id(), 'fork', task)),
434
+ clone: (task: Pick<TaskStartArgs, 'prompt' | 'title'>): Promise<Task> => started(act<TaskData>(id(), 'clone', task)),
421
435
  callTool: async (toolCall: ToolCallArgs): Promise<ToolResult> => (await withFiles(await act<ToolResult>(id(), 'callTool', toolCall))) as ToolResult,
422
436
  }) as unknown as Sessions['current']
423
437
  }
424
438
 
425
439
  const ownerOf = (session: string, data: TaskData): TaskOwner => {
426
440
  const parent = session === current.id ? current : known.get(session)
441
+ const isBackground = data.isBackground === true
427
442
  return sessionOf(
428
443
  () => ({
429
444
  id: data.id,
430
445
  title: data.title,
431
446
  project: parent?.project ?? '',
432
- agentType: data.agentType,
447
+ ...(data.agentType === undefined ? {} : { agentType: data.agentType }),
433
448
  model: data.model ?? '',
434
449
  cwd: parent?.cwd ?? '',
435
- isRunning: data.closedAt === undefined && (parent?.isRunning ?? true),
450
+ isRunning: data.closedAt === undefined && (isBackground || (parent?.isRunning ?? true)),
436
451
  startedAt: data.openedAt,
437
452
  lastAt: data.closedAt ?? data.openedAt,
453
+ ...(data.forkedFrom === undefined ? {} : { forkedFrom: data.forkedFrom }),
438
454
  }),
439
- { session, task: data.id },
455
+ isBackground ? undefined : { session, task: data.id },
456
+ true,
440
457
  )
441
458
  }
442
459
 
@@ -494,7 +511,11 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
494
511
  }
495
512
  const event: { readonly on: string; readonly before?: string } = processEvents[named as ProcessName]
496
513
  const hook = (isBefore ? event.before : undefined) ?? event.on
497
- if (method === 'beforeSessionStart') routeOn('session.compact', compactionNote)
514
+ if (method === 'beforeSessionStart') routeOn('session.compact', trackCompaction)
515
+ if (method === 'beforeModelChange') hookOn(event.on, () => {
516
+ const notes = modelNotes.splice(0)
517
+ return notes.length === 0 ? undefined : { additionalContext: notes }
518
+ })
498
519
  const failed = (input: unknown, error: Error) => {
499
520
  const refusal = refusalFor(method, processPayloadOf(named as ProcessFieldsName, input), error)
500
521
  return refusal === undefined ? undefined : hookAnswerOf(name, named, refusal)
@@ -504,35 +525,38 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
504
525
  async (input: unknown) => {
505
526
  const payload = processPayloadOf(named as ProcessFieldsName, input)
506
527
  if (named === 'SessionStart' && payload['source'] === 'compact') {
507
- const started = isBefore ? compactionStarts.shift() : undefined
508
- return started === undefined ? undefined : hookAnswerOf(name, named, started)
528
+ if (!isBefore) return undefined
529
+ const task = compacting?.agentId
530
+ const model = task === undefined ? await claude.session.model() : undefined
531
+ const answer = await raise(method, { ...payload, ...defined({ task, model, title: current.title || undefined }) })
532
+ if (answer === undefined) return undefined
533
+ return hookAnswerOf(name, named, task === undefined ? answer : defined({ note: answer['note'] }))
509
534
  }
510
535
  if (!isBefore) {
511
536
  void raise(method, payload)
512
537
  return undefined
513
538
  }
539
+ if (named === 'ModelChange') modelNotes.splice(0)
514
540
  const answer = await raise(method, payload)
515
- return answer === undefined ? undefined : hookAnswerOf(name, named, answer)
541
+ if (answer === undefined) return undefined
542
+ if (named === 'ModelChange' && typeof answer['note'] === 'string' && answer['refuse'] === undefined) modelNotes.push(modNote(name, answer['note']))
543
+ return hookAnswerOf(name, named, answer)
516
544
  },
517
545
  isBefore ? failed : undefined,
518
546
  )
519
547
  }
520
548
 
521
- const compactionStarts: HookFields[] = []
522
-
523
- const compactionNote = async (e: Args<'session.compact'>, next: (e: Args<'session.compact'>) => Promise<SessionCompactResult>): Promise<SessionCompactResult> => {
524
- const compacted = await next(e)
525
- if (e.trigger === 'precompute' || compacted.skip !== undefined) return compacted
526
- const model = e.agentId === undefined ? await claude.session.model() : undefined
527
- const answer = await raise('beforeSessionStart', { session: current.id, cwd: current.cwd, ...defined({ task: e.agentId, model, title: current.title || undefined, contextTokenCount: compacted.tokensAfter }), source: 'compact' })
528
- const { note, ...started } = answer ?? {}
529
- if (Object.keys(started).length > 0) compactionStarts.push(started)
530
- if (typeof note !== 'string') return compacted
531
- if (!isGranted('prompt')) {
532
- report('session:beforeSessionStart', 'The beforeSessionStart listener failed', new Error(`${name}: its note adds text Claude reads, which needs the prompt permission.`))
533
- return compacted
549
+ const modelNotes: string[] = []
550
+
551
+ let compacting: Args<'session.compact'> | undefined
552
+
553
+ const trackCompaction = async (e: Args<'session.compact'>, next: (e: Args<'session.compact'>) => Promise<SessionCompactResult>): Promise<SessionCompactResult> => {
554
+ compacting = e
555
+ try {
556
+ return await next(e)
557
+ } finally {
558
+ if (compacting === e) compacting = undefined
534
559
  }
535
- return { ...compacted, messages: [...compacted.messages, { role: 'user', text: modNote(name, note), toolUses: [] }] }
536
560
  }
537
561
 
538
562
  const listen = (method: string, args: readonly unknown[], isEveryAgent = false): Subscription => {
@@ -654,9 +678,9 @@ const modelChangeOf = (input: HookFields) => ({
654
678
 
655
679
  const answerKeys: Readonly<Record<string, readonly string[]>> = {
656
680
  Run: ['args', 'note', 'result', 'exitCode', 'refuse'],
657
- ProjectSetup: [],
681
+ ProjectSetup: ['note'],
658
682
  SessionStart: ['note', 'title', 'reloadSkills', 'firstPrompt'],
659
- ModelChange: ['ask', 'refuse'],
683
+ ModelChange: ['ask', 'refuse', 'note'],
660
684
  WorktreeCreate: ['result'],
661
685
  Elicitation: ['refuse'],
662
686
  ElicitationResponse: ['refuse'],
@@ -706,19 +730,20 @@ const processFields: { readonly [Name in ProcessFieldsName]: (input: HookFields)
706
730
  ReplyDisplay: (input) => ({ replyId: input['message_id'], index: input['index'], isLast: input['final'], text: input['delta'] }),
707
731
  }
708
732
 
709
- export async function spawnTask(claude: Pick<Claude, 'agent'>, task: TaskStartArgs): Promise<TaskData> {
733
+ export async function spawnTask(claude: Pick<Claude, 'agent'>, task: TaskStartArgs, forkedFrom?: string): Promise<TaskData> {
710
734
  if (task.title.trim() === '') throw new Error('a task needs a title.')
735
+ const { name, agentType, model, cwd }: TaskStartArgs = forkedFrom === undefined ? task : { prompt: task.prompt, title: task.title, agentType: forkAgentType }
711
736
  const started = await claude.agent.spawn({
712
737
  prompt: task.prompt,
713
738
  description: task.title,
714
- ...(task.name === undefined ? {} : { name: task.name }),
715
- ...(task.agentType === undefined ? {} : { subagentType: task.agentType }),
716
- ...(task.model === undefined ? {} : { model: task.model }),
717
- ...(task.cwd === undefined ? {} : { cwd: task.cwd }),
739
+ ...(name === undefined ? {} : { name }),
740
+ ...(agentType === undefined ? {} : { subagentType: agentType }),
741
+ ...(model === undefined ? {} : { model }),
742
+ ...(cwd === undefined ? {} : { cwd }),
718
743
  })
719
744
  if (started.deny !== undefined) throw new Error(`Claude Code refused the task: ${started.deny}`)
720
745
  if (started.agentId === undefined) throw new Error('Claude Code answered without starting a task.')
721
- return { id: started.agentId, title: task.title, agentType: task.agentType ?? 'general-purpose', model: started.model, openedBy: '', openedAt: new Date().toISOString() }
746
+ return { id: started.agentId, title: task.title, ...(forkedFrom === undefined ? { agentType: agentType ?? 'general-purpose' } : { forkedFrom }), model: started.model, openedBy: '', openedAt: new Date().toISOString() }
722
747
  }
723
748
 
724
749
  export function processPayloadOf(named: ProcessFieldsName, input: unknown): HookFields {
@@ -734,6 +759,7 @@ function hookAnswerOf(name: string, named: string, answer: HookFields): HookFiel
734
759
  if (refuse !== undefined) return { permissionDecision: 'deny', permissionDecisionReason: refuse }
735
760
  return typeof answer['ask'] === 'string' ? { permissionDecision: 'ask', permissionDecisionReason: answer['ask'] } : undefined
736
761
  }
762
+ if (named === 'ProjectSetup') return defined({ additionalContext: note })
737
763
  if (named === 'WorktreeCreate') return defined({ worktreePath: answer['result'] })
738
764
  if (named === 'PermissionDenial') return answer['retry'] === true ? { retry: true } : undefined
739
765
  if (named === 'ToolBatch') return defined({ additionalContext: note })
@@ -30,9 +30,11 @@ type Entry = {
30
30
  readonly options: ToastOptions
31
31
  readonly actions: readonly Action[]
32
32
  readonly hides?: string
33
+ at: Date
33
34
  isOpen: boolean
34
35
  reporting?: Reporting | undefined
35
36
  trying?: string | RenderElement | undefined
37
+ spinsFrom?: number | undefined
36
38
  timer: Timer | undefined
37
39
  }
38
40
 
@@ -46,6 +48,8 @@ const defaultDuration = 4000
46
48
  const visibleToasts = 3
47
49
  const spinner = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const
48
50
  const spinnerMs = 100
51
+ const spinFrames = 10_000 / spinnerMs
52
+ const still = '…'
49
53
  const icons: Record<Exclude<ToastType, 'loading'>, { readonly glyph: string; readonly color?: string }> = {
50
54
  default: { glyph: ' ' },
51
55
  success: { glyph: '✓', color: 'success' },
@@ -71,15 +75,20 @@ export function createToaster(claude: Claude, name: string, version: () => strin
71
75
  let showsAll = false
72
76
  let bandId: string | undefined
73
77
 
78
+ const isLoading = (entry: Entry) => entry.type === 'loading' || entry.trying !== undefined
79
+
80
+ const isSpinning = (entry: Entry) => entry.spinsFrom !== undefined && frame - entry.spinsFrom < spinFrames
81
+
74
82
  const changed = () => {
75
- const isLoading = [...entries.values()].some((entry) => entry.type === 'loading' || entry.trying !== undefined)
76
- if (isLoading && spinning === undefined) {
83
+ for (const entry of entries.values()) entry.spinsFrom = isLoading(entry) ? (entry.spinsFrom ?? frame) : undefined
84
+ const spins = [...entries.values()].some(isSpinning)
85
+ if (spins && spinning === undefined) {
77
86
  spinning = claude.clock.every(spinnerMs, () => {
78
87
  frame += 1
79
- claude.ui.invalidate('ui.render')
88
+ changed()
80
89
  })
81
90
  }
82
- if (!isLoading && spinning !== undefined) {
91
+ if (!spins && spinning !== undefined) {
83
92
  spinning.cancel()
84
93
  spinning = undefined
85
94
  }
@@ -121,7 +130,7 @@ export function createToaster(claude: Claude, name: string, version: () => strin
121
130
  }
122
131
  if (previous !== undefined) disarm(previous)
123
132
  const reporting = hides !== undefined && previous?.hides === hides ? previous.reporting : undefined
124
- const entry: Entry = { id, type, message, options, actions, ...(hides === undefined ? {} : { hides }), isOpen: previous?.isOpen ?? false, reporting, timer: undefined }
133
+ const entry: Entry = { id, type, message, options, actions, ...(hides === undefined ? {} : { hides }), at: new Date(), isOpen: previous?.isOpen ?? false, reporting, timer: undefined }
125
134
  if (type === 'error' && previous?.message !== message) claude.ui.log(`${name}: ${message}${typeof options.description === 'string' ? `: ${options.description}` : ''}`, { to: 'debug' })
126
135
  entries.set(id, entry)
127
136
  changed()
@@ -181,14 +190,19 @@ export function createToaster(claude: Claude, name: string, version: () => strin
181
190
  if (hidden.get(id) === hides) return id
182
191
  hidden.delete(id)
183
192
  const shown = entries.get(id)
184
- if (shown?.hides === hides && shown.trying === undefined && shown.options.description === description) return id
193
+ if (shown?.hides === hides && shown.trying === undefined && shown.options.description === description) {
194
+ const previous = clockTime(shown.at)
195
+ shown.at = new Date()
196
+ if (clockTime(shown.at) !== previous) changed()
197
+ return id
198
+ }
185
199
  const stack = error instanceof Error && error.stack !== undefined ? `\n\n${error.stack}` : ''
186
200
  const details = async () => {
187
201
  const modVersion = await claude.fs
188
202
  .read(`${claude.plugin.root}/.claude-plugin/plugin.json`)
189
203
  .then((text) => String((JSON.parse(text) as { version?: unknown }).version))
190
204
  .catch((failure: unknown) => `unknown (${messageOf(failure)})`)
191
- return `${name} ${modVersion}, @cmodjs/core ${coreVersion}\n${message}${description === undefined ? '' : `\n${description}`}${stack}`
205
+ return `${name} ${modVersion}, @cmodjs/core ${coreVersion}, at ${(entries.get(id)?.at ?? new Date()).toISOString()}\n${message}${description === undefined ? '' : `\n${description}`}${stack}`
192
206
  }
193
207
  const startReporting = async () => {
194
208
  const shown = entries.get(id)
@@ -233,13 +247,13 @@ export function createToaster(claude: Claude, name: string, version: () => strin
233
247
  const row = (entry: Entry): RenderElement => {
234
248
  const { description } = entry.options
235
249
  const isText = typeof description === 'string'
236
- const icon = entry.type === 'loading' || entry.trying !== undefined ? { glyph: spinner[frame % spinner.length] as string, color: 'claude' } : icons[entry.type]
250
+ const icon = entry.type === 'loading' || entry.trying !== undefined ? { glyph: isSpinning(entry) ? (spinner[frame % spinner.length] as string) : still, color: 'claude' } : icons[entry.type]
237
251
  const message = isText ? Button({ plain: true, key: `toast:${entry.id}:open`, label: entry.message, onPress: toggle(entry) }) : Text({ children: entry.message })
238
252
  const details = !isText ? description : Text({ dimColor: true, wrap: entry.isOpen ? 'wrap' : 'truncate-end', children: entry.isOpen ? description : firstLine(description) })
239
253
  const fixed = (child: RenderElement) => Box({ flexShrink: 0, children: child })
240
254
  const close = Button({ plain: true, dimColor: true, key: `toast:${entry.id}:close`, label: entry.hides === undefined ? '✕' : 'Hide', onPress: () => hide(entry) })
241
255
  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) }))
242
- const buttons = Box({ gap: 2, flexWrap: 'wrap', children: [...actions, close] })
256
+ const buttons = Box({ gap: 2, flexWrap: 'wrap', children: [...actions, close, Text({ dimColor: true, children: clockTime(entry.at) })].map(fixed) })
243
257
  const trying = entry.trying === undefined ? undefined : Box({ gap: 1, children: [Text({ dimColor: true, children: 'Trying again:' }), typeof entry.trying === 'string' ? Text({ dimColor: true, wrap: 'truncate-end', children: entry.trying }) : entry.trying] })
244
258
  const text = details === undefined ? [message] : [message, trying ?? details]
245
259
  const under = trying !== undefined && details === undefined ? [trying] : entry.reporting === undefined ? [buttons] : reportChoices(entry, entry.reporting)
@@ -362,3 +376,7 @@ function reportPrompt(name: string, contact: Contact | undefined, details: strin
362
376
  : `Email it to ${contact.address}.`
363
377
  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}`
364
378
  }
379
+
380
+ function clockTime(at: Date): string {
381
+ return `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
382
+ }