@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
@@ -3,17 +3,19 @@ import { consentPath, coreVersion, dataFolder, grantFix, isObject, parseConsent
3
3
  import { Message, rowOfAppend, type Row } from '../session/context/turn/message/message.js'
4
4
  import { modNote, Note } from '../session/context/turn/message/note.js'
5
5
  import { Person } from '../session/context/turn/message/sender.js'
6
- import { createConversation, emptyConversation, type Conversation, type ConversationState, type ContextData, type SessionChange, type SessionData, type TaskData, type TurnData, type TurnEnding } from '../session/conversation.js'
6
+ import { createConversation, emptyConversation, ownerOfSpawn, tasksWithAgents, type Conversation, type ConversationState, type ContextData, type SessionChange, type SessionData, type TaskData, type TurnData, type TurnEnding } from '../session/conversation.js'
7
7
  import { Session, UsageChange, type BeforeEvent, type Surface, type SessionSend, type TaskStartArgs, type ToolCallArgs } from '../session/session.js'
8
8
  import { GoalChange } from '../session/goal/goal.js'
9
9
  import { TurnEnd, TurnStart } from '../session/context/turn/turn.js'
10
+ import { TaskStart } from '../session/goal/task.js'
10
11
  import { receivedMatcher } from '../utils/matcher.js'
12
+ import { resolve } from '../vendor.js'
11
13
  import { ifMissing } from '../utils/paths.js'
12
14
  import { formatExit, messageOf } from '../utils/text.js'
13
- import { beforeAnswerKeys, foldBefore, installBefore, relayOf, type Place } from './before.js'
15
+ import { beforeAnswerKeys, foldBefore, installBefore, relayOf, type Decide, type Place } from './before.js'
14
16
  import type { Claude } from './claude.js'
15
17
  import { cmodPluginName } from './dependencies.js'
16
- import { readLines, runCmod } from './programs.js'
18
+ import { readLines, runCmod, spawnCmod } from './programs.js'
17
19
  import type { RoutedHook, Router } from './router.js'
18
20
  import { PassedTimeout } from './deadline.js'
19
21
  import { actPermission, conversationMethods, everySession, isAct, isHeardBy, isInScope, resultOf, spawnTask, usageOf, type Act, type Listened } from './sessions.js'
@@ -30,9 +32,16 @@ type Question =
30
32
  | { readonly kind: 'act'; readonly caller: string; readonly action: Act; readonly input: unknown; readonly task?: string }
31
33
  | { readonly kind: 'before'; readonly mod: string; readonly event: BeforeEvent; readonly change: SessionChange; readonly project: string }
32
34
 
33
- type Relay = (Question & { readonly id: string; readonly from: string }) | { readonly kind: 'answer'; readonly id: string; readonly value?: unknown; readonly deny?: string } | { readonly kind: 'watch' } | { readonly kind: 'goal'; readonly task?: string; readonly text: string }
35
+ type Relay =
36
+ | (Question & { readonly id: string; readonly from: string })
37
+ | { readonly kind: 'answer'; readonly id: string; readonly value?: unknown; readonly deny?: string }
38
+ | { readonly kind: 'watch' }
39
+ | { readonly kind: 'goal'; readonly task?: string; readonly text: string }
40
+ | { readonly kind: 'taskEnd'; readonly task: TaskData }
34
41
 
35
- const relayKinds: ReadonlySet<unknown> = new Set(['act', 'before', 'answer', 'watch', 'goal'] satisfies Relay['kind'][])
42
+ const relayKinds: ReadonlySet<unknown> = new Set(['act', 'before', 'answer', 'watch', 'goal', 'taskEnd'] satisfies Relay['kind'][])
43
+
44
+ const taskVariable = 'CMOD_TASK'
36
45
 
37
46
  const isRelay = (value: unknown): value is Relay => isObject(value) && relayKinds.has(value['kind'])
38
47
 
@@ -75,8 +84,12 @@ export function serveConversations({
75
84
  }): Conversations {
76
85
  const folder = dataFolder(store, cmodPluginName)
77
86
  const goals = `${folder}/goals`
87
+ const backgroundTasks = `${folder}/tasks`
78
88
  const watches = `${folder}/before`
89
+ const listeners = `${folder}/listeners`
79
90
  const heard = new Map<string, Map<string, readonly Heard[]>>()
91
+ const subscribed = new Map<string, Map<string, readonly Listened[]>>()
92
+ let savingListeners: Promise<unknown> = Promise.resolve()
80
93
  const asked = new Map<string, Asked>()
81
94
  const ownWatches = new Map<string, Watch>()
82
95
  let watchers: readonly Watcher[] = []
@@ -106,6 +119,11 @@ export function serveConversations({
106
119
  router.add('classic.Stop', async (e, next) => endTurn({ reason: 'answer', ...(e.last_assistant_message === undefined ? {} : { answer: e.last_assistant_message }) })(await next(e)))
107
120
  router.add('classic.StopFailure', async (e, next) => endTurn({ reason: 'error', failure: { type: String(e.error), text: e.error_details ?? e.last_assistant_message ?? String(e.error) } })(await next(e)))
108
121
  router.add('classic.SubagentStop', async (e, next) => endTurn({ reason: 'answer', ...(e.last_assistant_message === undefined ? {} : { answer: e.last_assistant_message }) }, e.agent_id)(await next(e)))
122
+ router.add('classic.Stop', async (e, next) => {
123
+ const below = await next(e)
124
+ if (below.block === undefined) void answerTask().catch((error: unknown) => report('task', 'Could not record that this session answered its task', error))
125
+ return below
126
+ })
109
127
 
110
128
  const json = async <Value>(args: readonly string[]): Promise<Value> => JSON.parse(await runCmod(claude, [...args, '--json', '--project', project()], await claude.session.id())) as Value
111
129
 
@@ -154,7 +172,7 @@ export function serveConversations({
154
172
  follower?.stop()
155
173
  follower = undefined
156
174
  if (args.length === 0) return
157
- const stream = claude.process.spawn({ argv: ['cmod', 'history', 'follow', ...args, '--json', '--project', project()], env: { CLAUDE_CODE_SESSION_ID: session } })
175
+ const stream = spawnCmod(claude, ['history', 'follow', ...args, '--json', '--project', project()], session)
158
176
  const started = { sessions: wanted, stop: () => void stream.return(undefined as never) }
159
177
  follower = started
160
178
  void readLines(stream, (line) => {
@@ -241,13 +259,39 @@ export function serveConversations({
241
259
  await Promise.all([...told].map((id) => post(id, { kind: 'watch' }).catch((error: unknown) => claude.ui.log(`cmod: session ${id} did not hear of a before listener: ${messageOf(error)}`, { to: 'debug' }))))
242
260
  }
243
261
 
262
+ const hear = (caller: string, id: string, list: readonly Listened[]) => {
263
+ const sessions = heard.get(caller) ?? new Map<string, readonly Heard[]>()
264
+ sessions.set(id, list.map(({ event, matcher }) => ({ event, matcher: matcher === undefined ? undefined : receivedMatcher(matcher) })))
265
+ heard.set(caller, sessions)
266
+ const lists = subscribed.get(caller) ?? new Map<string, readonly Listened[]>()
267
+ lists.set(id, list)
268
+ subscribed.set(caller, lists)
269
+ }
270
+
271
+ const saveListeners = (isEnding = false) => {
272
+ const at = session
273
+ const kept = isEnding ? {} : Object.fromEntries([...subscribed].map(([caller, lists]) => [caller, Object.fromEntries(lists)]))
274
+ const next = savingListeners.then(() => (at === '' ? undefined : claude.fs.write(`${listeners}/${at}.json`, JSON.stringify(kept))))
275
+ savingListeners = next.catch((error: unknown) => claude.ui.log(`cmod: saving the mods' listeners failed: ${messageOf(error)}`, { to: 'debug' }))
276
+ return next
277
+ }
278
+
279
+ const restoreListeners = async () => {
280
+ const kept = JSON.parse(await claude.fs.read(`${listeners}/${session}.json`).catch(ifMissing('{}'))) as Readonly<Record<string, Readonly<Record<string, readonly Listened[]>>>>
281
+ for (const [caller, lists] of Object.entries(kept)) if (!subscribed.has(caller)) for (const [id, list] of Object.entries(lists)) hear(caller, id, list)
282
+ }
283
+
244
284
  const readWatchers = async () => {
245
285
  const files = await claude.fs.list(watches).catch(ifMissing([]))
246
286
  const found: Watcher[] = []
247
287
  for (const file of files) {
248
288
  const from = file.name.replace(/\.json$/, '')
249
- if (from === session || !file.name.endsWith('.json')) continue
289
+ if (!file.name.endsWith('.json')) continue
250
290
  const list = JSON.parse(await claude.fs.read(`${watches}/${file.name}`).catch(ifMissing('[]'))) as readonly Watch[]
291
+ if (from === session) {
292
+ for (const watch of list) if (!ownWatches.has(`${watch.mod}\n${watch.target}`)) ownWatches.set(`${watch.mod}\n${watch.target}`, watch)
293
+ continue
294
+ }
251
295
  found.push(...list.filter((watch) => watch.target === session || (watch.target === everySession && watch.project === project())).map((watch) => ({ ...watch, from })))
252
296
  }
253
297
  watchers = found
@@ -299,6 +343,26 @@ export function serveConversations({
299
343
  }
300
344
  }
301
345
 
346
+ const wants = async (event: BeforeEvent) => {
347
+ await Promise.all([starting, watching])
348
+ return deciders(event).length > 0
349
+ }
350
+
351
+ const decide = (): Decide => {
352
+ let endsAt: Promise<number> | undefined
353
+ return (event, change) =>
354
+ foldBefore(
355
+ event,
356
+ change,
357
+ deciders(event).map((watcher) => async (latest: SessionChange) => {
358
+ if (watcher.from === session) return askHere(watcher.mod, event, latest)
359
+ endsAt ??= claude.clock.now().then((now) => now + awayFoldMs)
360
+ return askAway(watcher, event, latest, await endsAt)
361
+ }),
362
+ (error) => report(`before:${event}`, `Passed the ${event} on without a mod's answer`, error),
363
+ )
364
+ }
365
+
302
366
  installBefore({
303
367
  claude,
304
368
  router,
@@ -307,24 +371,8 @@ export function serveConversations({
307
371
  await starting
308
372
  return place()
309
373
  },
310
- wants: async (event) => {
311
- await Promise.all([starting, watching])
312
- return deciders(event).length > 0
313
- },
314
- fold: () => {
315
- let endsAt: Promise<number> | undefined
316
- return (event, change) =>
317
- foldBefore(
318
- event,
319
- change,
320
- deciders(event).map((watcher) => async (latest: SessionChange) => {
321
- if (watcher.from === session) return askHere(watcher.mod, event, latest)
322
- endsAt ??= claude.clock.now().then((now) => now + awayFoldMs)
323
- return askAway(watcher, event, latest, await endsAt)
324
- }),
325
- (error) => report(`before:${event}`, `Passed the ${event} on without a mod's answer`, error),
326
- )
327
- },
374
+ wants,
375
+ fold: decide,
328
376
  },
329
377
  })
330
378
 
@@ -345,8 +393,12 @@ export function serveConversations({
345
393
  },
346
394
  (error: unknown) => report('state', `Starts session ${id} without its earlier turns`, error),
347
395
  )
348
- follow()
396
+ const restored = restoreListeners()
397
+ .catch((error: unknown) => report('listeners', "Could not read back the mods' listeners of this session", error))
398
+ .then(follow)
399
+ starting = Promise.all([starting, restored]).then(() => undefined)
349
400
  reloadWatchers()
401
+ if (subscribed.size > 0) void Promise.all([previous === '' ? undefined : claude.fs.write(`${listeners}/${previous}.json`, '{}'), saveListeners()]).catch((error: unknown) => report('follow', 'Kept telling the mods about the session from before', error))
350
402
  if (ownWatches.size === 0) return
351
403
  void Promise.all([previous === '' ? undefined : claude.fs.write(`${watches}/${previous}.json`, '[]'), publishWatches()]).catch((error: unknown) => report('before', 'Kept its before listeners on the session from before', error))
352
404
  }
@@ -375,6 +427,7 @@ export function serveConversations({
375
427
  })
376
428
  router.add('classic.SessionEnd', async (e, next) => {
377
429
  if (ownWatches.size > 0) await publishWatches(true).catch((error: unknown) => report('before', 'Left its before listeners registered', error))
430
+ if (subscribed.size > 0) await saveListeners(true).catch((error: unknown) => report('follow', 'Left its listeners registered', error))
378
431
  return next(e)
379
432
  })
380
433
 
@@ -407,8 +460,8 @@ export function serveConversations({
407
460
  const spawned = await next(e)
408
461
  if (spawned.deny !== undefined || spawned.agentId === undefined) return spawned
409
462
  const id = spawned.agentId
410
- const opened: TaskData = { id, title: e.description, agentType: e.subagentType, model: spawned.model, openedBy: e.tool_use_id, openedAt: new Date().toISOString(), ...(e.parentAgentId === undefined ? {} : { parent: e.parentAgentId }) }
411
463
  await starting
464
+ const opened: TaskData = { id, title: e.description, ...ownerOfSpawn(e.subagentType, session), model: spawned.model, openedBy: e.tool_use_id, openedAt: new Date().toISOString(), ...(e.parentAgentId === undefined ? {} : { parent: e.parentAgentId }) }
412
465
  dispatch(session, conversation.startTask(opened))
413
466
  const waiting = waitingRows.get(id) ?? []
414
467
  waitingRows.delete(id)
@@ -445,6 +498,11 @@ export function serveConversations({
445
498
  changeGoal(relay.task, relay.text)
446
499
  return
447
500
  }
501
+ if (relay.kind === 'taskEnd') {
502
+ await starting
503
+ dispatch(session, conversation.endTask(relay.task, relay.task.closedAt ?? new Date().toISOString()))
504
+ return
505
+ }
448
506
  const answered = async (run: () => Promise<unknown>) => {
449
507
  try {
450
508
  await post(relay.from, { kind: 'answer', id: relay.id, value: (await run()) ?? null })
@@ -477,6 +535,9 @@ export function serveConversations({
477
535
  return null
478
536
  }
479
537
  if (action === 'startTask') return spawnTask(claude, input as TaskStartArgs)
538
+ if (action === 'fork') return spawnTask(claude, input as TaskStartArgs, session)
539
+ if (action === 'startBackgroundTask') return startBackground(input as TaskStartArgs)
540
+ if (action === 'clone') return startBackground(input as TaskStartArgs, session)
480
541
  const { name, input: toolInput } = input as ToolCallArgs
481
542
  return resultOf(place(), { name, input: toolInput, cwd: await claude.session.cwd() }, await claude.tool.call({ ...toolInput, tool: name } as never))
482
543
  }
@@ -503,6 +564,48 @@ export function serveConversations({
503
564
  return null
504
565
  }
505
566
 
567
+ const startBackground = async (task: TaskStartArgs, forkedFrom?: string): Promise<TaskData> => {
568
+ if (task.title.trim() === '') throw new Error('a task needs a title.')
569
+ if (task.name !== undefined) throw new Error('a background task takes no name.')
570
+ const fresh: Partial<TaskStartArgs> = forkedFrom === undefined ? task : {}
571
+ const asked: TaskData = { id: '', title: task.title, ...(forkedFrom === undefined ? {} : { forkedFrom }), ...(fresh.agentType === undefined ? {} : { agentType: fresh.agentType }), ...(fresh.model === undefined ? {} : { model: fresh.model }), openedBy: '', openedAt: new Date().toISOString(), isBackground: true }
572
+ const answer = (await wants(TaskStart)) ? await decide()(TaskStart, { type: TaskStart, task: { ...asked, context: conversation.state.context } }) : undefined
573
+ if (answer?.refuse !== undefined) throw new Error(`a beforeTaskStart listener refused the task: ${answer.refuse}`)
574
+ const owner = forkedFrom === undefined ? (answer?.owner ?? {}) : {}
575
+ const folder = owner.cwd ?? fresh.cwd
576
+ const here = await claude.session.cwd()
577
+ const args = [
578
+ 'task',
579
+ 'start',
580
+ '--title',
581
+ answer?.title ?? task.title,
582
+ ...(forkedFrom === undefined ? [] : ['--clone']),
583
+ ...textArg('--agent', owner.agentType ?? fresh.agentType),
584
+ ...textArg('--model', owner.model ?? fresh.model),
585
+ '--cwd',
586
+ folder === undefined ? here : resolve(here, folder),
587
+ '--json',
588
+ '--',
589
+ answer?.prompt ?? task.prompt,
590
+ ]
591
+ const started = JSON.parse(await runCmod(claude, args, session)) as TaskData
592
+ dispatch(session, conversation.startTask(started))
593
+ return started
594
+ }
595
+
596
+ const answerTask = async () => {
597
+ const { env } = await claude.settings.read()
598
+ const asked = isObject(env) ? env[taskVariable] : undefined
599
+ if (typeof asked !== 'string') return
600
+ const answered = `cmod:task-answered:${session}`
601
+ if ((await claude.store.get(answered)) === true) return
602
+ await claude.store.set(answered, true)
603
+ const { session: from, ...task } = JSON.parse(asked) as TaskData & { readonly session: string }
604
+ const ended: TaskData = { ...task, id: session, closedAt: new Date().toISOString() }
605
+ await claude.fs.write(`${backgroundTasks}/${from}/${session}.json`, JSON.stringify(ended))
606
+ await post(from, { kind: 'taskEnd', task: ended }).catch((error: unknown) => claude.ui.log(`cmod: session ${from} did not hear that its task ${session} answered: ${messageOf(error)}`, { to: 'debug' }))
607
+ }
608
+
506
609
  const actElsewhere = async (caller: string, target: string, action: Act, input: unknown, task: string | undefined): Promise<unknown> => {
507
610
  await checkAct(caller, action, input)
508
611
  const isLive = await isRunning(target)
@@ -531,12 +634,14 @@ export function serveConversations({
531
634
  Object.values(conversation.state.tasks).map((task) => ({
532
635
  id: task.id,
533
636
  title: task.title,
534
- agentType: task.agentType,
637
+ ...(task.agentType === undefined ? {} : { agentType: task.agentType }),
535
638
  ...(task.model === undefined ? {} : { model: task.model }),
536
639
  openedBy: task.openedBy,
537
640
  openedAt: task.openedAt,
538
641
  ...(task.closedAt === undefined ? {} : { closedAt: task.closedAt }),
539
642
  ...(task.parent === undefined ? {} : { parent: task.parent }),
643
+ ...(task.forkedFrom === undefined ? {} : { forkedFrom: task.forkedFrom }),
644
+ ...(task.isBackground === true ? { isBackground: true as const } : {}),
540
645
  }))
541
646
 
542
647
  const liveContext = (task: unknown): ContextData[] => {
@@ -600,7 +705,9 @@ export function serveConversations({
600
705
  if (id !== session) return saved
601
706
  await starting
602
707
  const kept = liveTasks().filter((task) => isNested || task.parent === request['task'])
603
- return merged(saved, kept, (task) => task.id)
708
+ const running = await claude.agent.list().catch((error: unknown) => (claude.ui.log(`cmod: the tasks show no status, because Claude Code listed no agents: ${messageOf(error)}`, { to: 'debug' }), []))
709
+ const background = saved.flatMap(({ id, status }) => (status === undefined ? [] : [{ id, status }]))
710
+ return tasksWithAgents(merged(saved, kept, (task) => task.id), [...running, ...background])
604
711
  },
605
712
  async [conversationMethods.goal](request) {
606
713
  const id = String(request['session'])
@@ -629,10 +736,9 @@ export function serveConversations({
629
736
  if (caller === undefined) throw new Error('cmod hears no caller for this subscription.')
630
737
  const id = String(request['session'])
631
738
  const list = Array.isArray(request['subscriptions']) ? (request['subscriptions'] as readonly Listened[]) : []
632
- const sessions = heard.get(caller) ?? new Map<string, readonly Heard[]>()
633
- sessions.set(id, list.map(({ event, matcher }) => ({ event, matcher: matcher === undefined ? undefined : receivedMatcher(matcher) })))
634
- heard.set(caller, sessions)
739
+ hear(caller, id, list)
635
740
  follow()
741
+ await saveListeners()
636
742
  return null
637
743
  },
638
744
  async [conversationMethods.watchBefore](request, caller) {
@@ -1,3 +1,4 @@
1
+ import type { HookStream, ProcessSpawnChunk, ProcessSpawnRequest, ProcessSpawnResult } from 'claude-code'
1
2
  import type { Claude } from './claude.js'
2
3
  import type { Pages } from './pages.js'
3
4
  import type { Router } from './router.js'
@@ -12,6 +13,7 @@ export type Engine = {
12
13
  readonly report: (failure: Failure) => void
13
14
  readonly hasPermissionCheck: () => boolean
14
15
  readonly answerInput: <Answer>(run: () => Promise<Answer>, passOn: () => Promise<unknown>) => Promise<Answer>
16
+ readonly spawnCmod: (args: readonly string[], program: ProcessSpawnRequest) => Promise<HookStream<ProcessSpawnChunk, ProcessSpawnResult>>
15
17
  readonly widgets?: Widgets
16
18
  readonly pages?: Pages
17
19
  }
@@ -49,6 +49,10 @@ export async function checkGrant(grants: Grants, call: string, item: string | un
49
49
  if (!isCovered(grants.granted(), item, grants.home)) throw new PermissionRefused(refusal(grants, call, item))
50
50
  }
51
51
 
52
+ export async function checkRun(grants: Grants, call: 'process.run' | 'process.spawn', argv: readonly string[]): Promise<void> {
53
+ await checkGrant(grants, `${call}(${argv.join(' ')})`, `run:${programOf(argv)}`)
54
+ }
55
+
52
56
  export async function checkWrite(grants: Grants, call: string, path: string): Promise<void> {
53
57
  await checkGrant(grants, call, writeItem(grants, path))
54
58
  }
@@ -63,11 +67,11 @@ export function checkingGrants(claude: Claude, grants: Grants): Claude {
63
67
  ...claude,
64
68
  process: {
65
69
  async run(argv, init) {
66
- await gate(`process.run(${argv.join(' ')})`, `run:${programOf(argv)}`)
70
+ await checkRun(grants, 'process.run', argv)
67
71
  return claude.process.run(argv, init)
68
72
  },
69
73
  spawn(request) {
70
- const started = gate(`process.spawn(${request.argv.join(' ')})`, `run:${programOf(request.argv)}`).then(() => claude.process.spawn(request))
74
+ const started = checkRun(grants, 'process.spawn', request.argv).then(() => claude.process.spawn(request))
71
75
  return spawned(started)
72
76
  },
73
77
  },
@@ -3,6 +3,7 @@ import { boundCombos, hotkeyCommand } from '../records.js'
3
3
  import { ifMissing } from '../utils/paths.js'
4
4
  import { messageOf } from '../utils/text.js'
5
5
  import type { Claude } from './claude.js'
6
+ import { cmodPluginName } from './dependencies.js'
6
7
  import type { Router } from './router.js'
7
8
  import type { Failure } from './toaster.js'
8
9
 
@@ -34,6 +35,12 @@ export async function createHotkeys({
34
35
  readonly answerInput: <Answer>(run: () => Promise<Answer>, passOn: () => Promise<unknown>) => Promise<Answer>
35
36
  readonly report: (failure: Failure) => void
36
37
  }): Promise<Readonly<Record<string, Hotkey>>> {
38
+ if (name === cmodPluginName) {
39
+ router.add('command.describe', async (e, next) => {
40
+ const described = await next(e)
41
+ return e.command.startsWith(hotkeyCommand(e.provider.plugin, '')) ? { ...described, isHidden: true } : described
42
+ })
43
+ }
37
44
  const entries = Object.entries(declared)
38
45
  if (entries.length === 0) return {}
39
46
  const combos = await readCombos(claude, configRoot)
@@ -46,10 +53,6 @@ export async function createHotkeys({
46
53
  (error: unknown) => report({ id: `hotkey:${hotkey}`, message: `The ${title} hotkey is not added`, error }),
47
54
  )
48
55
  }
49
- router.add('command.describe', async (e, next) => {
50
- const described = await next(e)
51
- return commands.has(e.command) ? { ...described, isHidden: true } : described
52
- })
53
56
  router.add('command.run', async (e, next) => {
54
57
  const hotkey = commands.get(e.command)
55
58
  if (hotkey === undefined) return next(e)