@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
@@ -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'
@@ -39,11 +39,21 @@ export const conversationMethods = {
39
39
  act: 'cmod:act',
40
40
  changed: 'cmod:changed',
41
41
  before: 'cmod:before',
42
+ sessionColor: 'cmod:sessionColor',
42
43
  } as const
43
44
 
44
- export type Act = 'send' | 'startTask' | 'callTool' | 'usage' | 'surfaces' | 'abort'
45
+ export type SessionColor = { readonly session: string; readonly color?: string }
45
46
 
46
- const actPermissions: Readonly<Record<Exclude<Act, 'callTool'>, string>> = { send: 'prompt', startTask: 'tasks', usage: 'conversation', surfaces: 'conversation', abort: 'prompt' }
47
+ const resetColors: readonly string[] = ['default', 'reset', 'none', 'gray', 'grey']
48
+
49
+ export function pickedColor(args: string): string | undefined {
50
+ const picked = args.trim().toLowerCase()
51
+ return resetColors.includes(picked) ? undefined : picked
52
+ }
53
+
54
+ export type Act = 'send' | 'startTask' | 'fork' | 'clone' | 'startBackgroundTask' | 'callTool' | 'usage' | 'surfaces' | 'abort'
55
+
56
+ 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
57
 
48
58
  const startsTurns: readonly string[] = ['CronCreate']
49
59
 
@@ -51,6 +61,11 @@ const ownSchedule: readonly string[] = ['CronDelete', 'CronList']
51
61
 
52
62
  export const beforePermission = (event: BeforeEvent): string => (event === 'toolCall' ? 'tools' : event === TaskStart ? 'tasks' : 'prompt')
53
63
 
64
+ const answerPermissions = (event: BeforeEvent, answer: Partial<Answers>): string[] => [
65
+ ...(Object.keys(answer).some((key) => key !== 'note') ? [beforePermission(event)] : []),
66
+ ...(answer.note === undefined ? [] : ['prompt']),
67
+ ]
68
+
54
69
  export const isAct = (action: string): action is Act => action === 'callTool' || action in actPermissions
55
70
 
56
71
  export function toolPermission(tool: string): string | undefined {
@@ -72,7 +87,8 @@ export type SessionsClient = {
72
87
  listen(method: string, args: readonly unknown[]): Subscription
73
88
  turn(data: TurnData): Turn
74
89
  /** An empty title keeps the title the session already has. */
75
- follow(current: Omit<SessionData, 'isRunning' | 'lastAt'>): void
90
+ follow(current: Omit<SessionData, 'isRunning' | 'lastAt' | 'color'>): void
91
+ recolor(told: SessionColor): void
76
92
  }
77
93
 
78
94
  type Listening = { scope: Scope; readonly event: SessionEvent; readonly method: string; readonly matcher: unknown; readonly listener: (payload: never, time: ListenTime) => unknown; readonly options: ListenOptions }
@@ -82,7 +98,8 @@ type Owned = { readonly session: string; readonly task: string }
82
98
  export async function usageOf(claude: Pick<Claude, 'session'>): Promise<Usage> {
83
99
  const [model, usage] = await Promise.all([claude.session.model(), claude.session.usage()])
84
100
  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 } }) }
101
+ const rateLimits = usage.rateLimits.map(({ kind, percentUsed, resetsAt }) => ({ type: kind, percent: percentUsed, ...(resetsAt === undefined ? {} : { resetsAt }) }))
102
+ return { model, contextWindow: { ...(tokens === undefined ? {} : { tokenCount: tokens }), size: window, ...(percent === undefined ? {} : { percent }) }, rateLimits, ...(usage.cost === undefined ? {} : { cost: { usd: usage.cost.usd } }) }
86
103
  }
87
104
 
88
105
  export function payloadOf(change: SessionChange): unknown {
@@ -90,8 +107,9 @@ export function payloadOf(change: SessionChange): unknown {
90
107
  if ('turn' in change) return change.turn
91
108
  if ('context' in change) return change.context
92
109
  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 } }
110
+ const { id, title, agentType, model, openedBy, openedAt, closedAt, isBackground, forkedFrom } = change.task
111
+ const owner = { id, title, ...(agentType === undefined ? {} : { agentType }), ...(model === undefined ? {} : { model }), ...(forkedFrom === undefined ? {} : { forkedFrom }), startedAt: openedAt }
112
+ return { id, title, openedBy, openedAt, ...(closedAt === undefined ? {} : { closedAt }), ...(isBackground === true ? { isBackground } : {}), owner }
95
113
  }
96
114
  if ('usage' in change) return change.usage
97
115
  return change.goal
@@ -149,6 +167,9 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
149
167
  const parents = new Map<string, Map<string, string | undefined>>()
150
168
  const known = new Map<string, SessionData>()
151
169
  let current: SessionData = { id: '', title: '', project: '', cwd: '', model: '', startedAt: '', lastAt: '', isRunning: true }
170
+ let told: SessionColor = { session: '' }
171
+
172
+ const colored = ({ color: _color, ...data }: SessionData): SessionData => (told.session === data.id && told.color !== undefined ? { ...data, color: told.color } : data)
152
173
 
153
174
  const keyOf = (scope: Scope) => scope.session ?? everySession
154
175
 
@@ -224,8 +245,10 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
224
245
  if (!decides(entry, session, latest)) return undefined
225
246
  const payload = await payloadFor(session, latest)
226
247
  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}`)
248
+ if (answer === undefined || answer === null || (typeof answer === 'object' && 'refuse' in answer)) return answer
249
+ const missing = answerPermissions(event, answer as Partial<Answers>).find((permission) => !isGranted(permission))
250
+ if (missing === undefined) return answer
251
+ throw new Error(`Its ${entry.method} answer changes nothing until you grant ${name} "${permissionWords(missing)}". ${grantFix}`)
229
252
  })
230
253
  const answer = await foldBefore(event, change, askers, (error) => report(`session:${methodOf('before', event)}`, `The ${methodOf('before', event)} listener failed`, error))
231
254
  return answer?.note === undefined ? answer : { ...answer, note: modNote(name, answer.note) }
@@ -293,17 +316,25 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
293
316
  }
294
317
  }
295
318
 
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
- })
319
+ const taskOf = (session: string, data: TaskData): Task => {
320
+ const record = data.isBackground === true ? { session: data.id } : { session, task: data.id }
321
+ return {
322
+ id: data.id,
323
+ title: data.title,
324
+ openedBy: data.openedBy,
325
+ openedAt: data.openedAt,
326
+ ...(data.closedAt === undefined ? {} : { closedAt: data.closedAt }),
327
+ ...(data.status === undefined ? {} : { status: data.status }),
328
+ ...(data.name === undefined ? {} : { name: data.name }),
329
+ ...(data.spawnedBy === undefined ? {} : { spawnedBy: data.spawnedBy }),
330
+ ...(data.isBackground === true ? { isBackground: true } : {}),
331
+ owner: ownerOf(session, data),
332
+ ...recordPart({ session, task: data.id }),
333
+ search: (text: string, matcher?: unknown) => searched({ text, ...record }, matcher),
334
+ tasks: async () => (await call<TaskData[]>(conversationMethods.tasks, record)).map((task) => taskOf(record.session, task)),
335
+ messages: () => messagesFrom(conversationMethods.messages, record),
336
+ }
337
+ }
307
338
 
308
339
  const goalOf = (where: () => Scope): Goal =>
309
340
  ({
@@ -345,11 +376,9 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
345
376
  if ('drop' in submitted && typeof submitted.drop === 'string') throw new Error(`${name}: Claude Code dropped the prompt: ${submitted.drop}`)
346
377
  }
347
378
 
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
379
  const surfacesHere = async (): Promise<Surface[]> => [...(await claude.session.surfaces())]
351
380
 
352
- const sessionOf = (read: () => SessionData, owned?: Owned) => {
381
+ const sessionOf = (read: () => SessionData, owned?: Owned, isOwner = owned !== undefined) => {
353
382
  const id = () => read().id
354
383
  const isHere = () => owned === undefined && id() === current.id
355
384
  const scope = (): Scope => (owned === undefined ? { session: id(), isAgent: true } : { session: owned.session, task: owned.task, isAgent: true })
@@ -363,6 +392,9 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
363
392
  get title() {
364
393
  return read().title
365
394
  },
395
+ get color() {
396
+ return read().color
397
+ },
366
398
  get project() {
367
399
  return read().project
368
400
  },
@@ -411,32 +443,34 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
411
443
  await act(id(), 'send', message, owned)
412
444
  },
413
445
  }
414
- if (owned !== undefined) return session as unknown as Sessions['current']
446
+ if (isOwner) return session as unknown as Sessions['current']
447
+ const started = async (starting: Promise<TaskData>): Promise<Task> => taskOf(id(), await starting.catch((error: unknown) => Promise.reject(new Error(`${name}: ${messageOf(error)}`))))
415
448
  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
- },
449
+ startTask: (task: TaskStartArgs): Promise<Task> => started(task.isBackground === true ? act<TaskData>(id(), 'startBackgroundTask', task) : isHere() ? spawnTask(claude, task) : act<TaskData>(id(), 'startTask', task)),
450
+ fork: (task: Pick<TaskStartArgs, 'prompt' | 'title'>): Promise<Task> => started(isHere() ? spawnTask(claude, task, current.id) : act<TaskData>(id(), 'fork', task)),
451
+ clone: (task: Pick<TaskStartArgs, 'prompt' | 'title'>): Promise<Task> => started(act<TaskData>(id(), 'clone', task)),
421
452
  callTool: async (toolCall: ToolCallArgs): Promise<ToolResult> => (await withFiles(await act<ToolResult>(id(), 'callTool', toolCall))) as ToolResult,
422
453
  }) as unknown as Sessions['current']
423
454
  }
424
455
 
425
456
  const ownerOf = (session: string, data: TaskData): TaskOwner => {
426
457
  const parent = session === current.id ? current : known.get(session)
458
+ const isBackground = data.isBackground === true
427
459
  return sessionOf(
428
460
  () => ({
429
461
  id: data.id,
430
462
  title: data.title,
431
463
  project: parent?.project ?? '',
432
- agentType: data.agentType,
464
+ ...(data.agentType === undefined ? {} : { agentType: data.agentType }),
433
465
  model: data.model ?? '',
434
466
  cwd: parent?.cwd ?? '',
435
- isRunning: data.closedAt === undefined && (parent?.isRunning ?? true),
467
+ isRunning: data.closedAt === undefined && (isBackground || (parent?.isRunning ?? true)),
436
468
  startedAt: data.openedAt,
437
469
  lastAt: data.closedAt ?? data.openedAt,
470
+ ...(data.forkedFrom === undefined ? {} : { forkedFrom: data.forkedFrom }),
438
471
  }),
439
- { session, task: data.id },
472
+ isBackground ? undefined : { session, task: data.id },
473
+ true,
440
474
  )
441
475
  }
442
476
 
@@ -494,7 +528,11 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
494
528
  }
495
529
  const event: { readonly on: string; readonly before?: string } = processEvents[named as ProcessName]
496
530
  const hook = (isBefore ? event.before : undefined) ?? event.on
497
- if (method === 'beforeSessionStart') routeOn('session.compact', compactionNote)
531
+ if (method === 'beforeSessionStart') routeOn('session.compact', trackCompaction)
532
+ if (method === 'beforeModelChange') hookOn(event.on, () => {
533
+ const notes = modelNotes.splice(0)
534
+ return notes.length === 0 ? undefined : { additionalContext: notes }
535
+ })
498
536
  const failed = (input: unknown, error: Error) => {
499
537
  const refusal = refusalFor(method, processPayloadOf(named as ProcessFieldsName, input), error)
500
538
  return refusal === undefined ? undefined : hookAnswerOf(name, named, refusal)
@@ -504,35 +542,38 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
504
542
  async (input: unknown) => {
505
543
  const payload = processPayloadOf(named as ProcessFieldsName, input)
506
544
  if (named === 'SessionStart' && payload['source'] === 'compact') {
507
- const started = isBefore ? compactionStarts.shift() : undefined
508
- return started === undefined ? undefined : hookAnswerOf(name, named, started)
545
+ if (!isBefore) return undefined
546
+ const task = compacting?.agentId
547
+ const model = task === undefined ? await claude.session.model() : undefined
548
+ const answer = await raise(method, { ...payload, ...defined({ task, model, title: current.title || undefined }) })
549
+ if (answer === undefined) return undefined
550
+ return hookAnswerOf(name, named, task === undefined ? answer : defined({ note: answer['note'] }))
509
551
  }
510
552
  if (!isBefore) {
511
553
  void raise(method, payload)
512
554
  return undefined
513
555
  }
556
+ if (named === 'ModelChange') modelNotes.splice(0)
514
557
  const answer = await raise(method, payload)
515
- return answer === undefined ? undefined : hookAnswerOf(name, named, answer)
558
+ if (answer === undefined) return undefined
559
+ if (named === 'ModelChange' && typeof answer['note'] === 'string' && answer['refuse'] === undefined) modelNotes.push(modNote(name, answer['note']))
560
+ return hookAnswerOf(name, named, answer)
516
561
  },
517
562
  isBefore ? failed : undefined,
518
563
  )
519
564
  }
520
565
 
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
566
+ const modelNotes: string[] = []
567
+
568
+ let compacting: Args<'session.compact'> | undefined
569
+
570
+ const trackCompaction = async (e: Args<'session.compact'>, next: (e: Args<'session.compact'>) => Promise<SessionCompactResult>): Promise<SessionCompactResult> => {
571
+ compacting = e
572
+ try {
573
+ return await next(e)
574
+ } finally {
575
+ if (compacting === e) compacting = undefined
534
576
  }
535
- return { ...compacted, messages: [...compacted.messages, { role: 'user', text: modNote(name, note), toolUses: [] }] }
536
577
  }
537
578
 
538
579
  const listen = (method: string, args: readonly unknown[], isEveryAgent = false): Subscription => {
@@ -608,7 +649,7 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
608
649
  follow(next) {
609
650
  const moved = next.id !== current.id && current.id !== ''
610
651
  const previous = current.id
611
- current = { ...next, title: next.title || (next.id === current.id ? current.title : ''), isRunning: true, lastAt: new Date().toISOString() }
652
+ current = colored({ ...next, title: next.title || (next.id === current.id ? current.title : ''), isRunning: true, lastAt: new Date().toISOString() })
612
653
  if (!moved) return
613
654
  for (const into of [listening, deciding]) {
614
655
  const kept = into.get(previous)
@@ -621,6 +662,10 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
621
662
  subscribe(previous)
622
663
  subscribe(current.id)
623
664
  },
665
+ recolor(next) {
666
+ told = next
667
+ current = colored(current)
668
+ },
624
669
  }
625
670
  }
626
671
 
@@ -654,9 +699,9 @@ const modelChangeOf = (input: HookFields) => ({
654
699
 
655
700
  const answerKeys: Readonly<Record<string, readonly string[]>> = {
656
701
  Run: ['args', 'note', 'result', 'exitCode', 'refuse'],
657
- ProjectSetup: [],
702
+ ProjectSetup: ['note'],
658
703
  SessionStart: ['note', 'title', 'reloadSkills', 'firstPrompt'],
659
- ModelChange: ['ask', 'refuse'],
704
+ ModelChange: ['ask', 'refuse', 'note'],
660
705
  WorktreeCreate: ['result'],
661
706
  Elicitation: ['refuse'],
662
707
  ElicitationResponse: ['refuse'],
@@ -706,19 +751,20 @@ const processFields: { readonly [Name in ProcessFieldsName]: (input: HookFields)
706
751
  ReplyDisplay: (input) => ({ replyId: input['message_id'], index: input['index'], isLast: input['final'], text: input['delta'] }),
707
752
  }
708
753
 
709
- export async function spawnTask(claude: Pick<Claude, 'agent'>, task: TaskStartArgs): Promise<TaskData> {
754
+ export async function spawnTask(claude: Pick<Claude, 'agent'>, task: TaskStartArgs, forkedFrom?: string): Promise<TaskData> {
710
755
  if (task.title.trim() === '') throw new Error('a task needs a title.')
756
+ const { name, agentType, model, cwd }: TaskStartArgs = forkedFrom === undefined ? task : { prompt: task.prompt, title: task.title, agentType: forkAgentType }
711
757
  const started = await claude.agent.spawn({
712
758
  prompt: task.prompt,
713
759
  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 }),
760
+ ...(name === undefined ? {} : { name }),
761
+ ...(agentType === undefined ? {} : { subagentType: agentType }),
762
+ ...(model === undefined ? {} : { model }),
763
+ ...(cwd === undefined ? {} : { cwd }),
718
764
  })
719
765
  if (started.deny !== undefined) throw new Error(`Claude Code refused the task: ${started.deny}`)
720
766
  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() }
767
+ return { id: started.agentId, title: task.title, ...(forkedFrom === undefined ? { agentType: agentType ?? 'general-purpose' } : { forkedFrom }), model: started.model, openedBy: '', openedAt: new Date().toISOString() }
722
768
  }
723
769
 
724
770
  export function processPayloadOf(named: ProcessFieldsName, input: unknown): HookFields {
@@ -734,6 +780,7 @@ function hookAnswerOf(name: string, named: string, answer: HookFields): HookFiel
734
780
  if (refuse !== undefined) return { permissionDecision: 'deny', permissionDecisionReason: refuse }
735
781
  return typeof answer['ask'] === 'string' ? { permissionDecision: 'ask', permissionDecisionReason: answer['ask'] } : undefined
736
782
  }
783
+ if (named === 'ProjectSetup') return defined({ additionalContext: note })
737
784
  if (named === 'WorktreeCreate') return defined({ worktreePath: answer['result'] })
738
785
  if (named === 'PermissionDenial') return answer['retry'] === true ? { retry: true } : undefined
739
786
  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
+ }