@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
@@ -3,20 +3,22 @@ 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
- import { actPermission, conversationMethods, everySession, isAct, isHeardBy, isInScope, resultOf, spawnTask, usageOf, type Act, type Listened } from './sessions.js'
21
+ import { actPermission, conversationMethods, everySession, isAct, isHeardBy, isInScope, pickedColor, resultOf, spawnTask, usageOf, type Act, type Listened, type SessionColor } from './sessions.js'
20
22
 
21
23
  type Request = Readonly<Record<string, unknown>>
22
24
 
@@ -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
 
@@ -52,6 +61,8 @@ export type Conversations = {
52
61
  answer(e: Frozen<Args<'cmod.call'>> & { readonly caller?: string }): Promise<EventResult<'cmod.call'> | undefined>
53
62
  moved(): void
54
63
  turnClosedBy(rowId: string): TurnData | undefined
64
+ color(): SessionColor
65
+ onColor(listener: (told: SessionColor) => void): void
55
66
  }
56
67
 
57
68
  const keptClosings = 200
@@ -75,8 +86,12 @@ export function serveConversations({
75
86
  }): Conversations {
76
87
  const folder = dataFolder(store, cmodPluginName)
77
88
  const goals = `${folder}/goals`
89
+ const backgroundTasks = `${folder}/tasks`
78
90
  const watches = `${folder}/before`
91
+ const listeners = `${folder}/listeners`
79
92
  const heard = new Map<string, Map<string, readonly Heard[]>>()
93
+ const subscribed = new Map<string, Map<string, readonly Listened[]>>()
94
+ let savingListeners: Promise<unknown> = Promise.resolve()
80
95
  const asked = new Map<string, Asked>()
81
96
  const ownWatches = new Map<string, Watch>()
82
97
  let watchers: readonly Watcher[] = []
@@ -106,6 +121,11 @@ export function serveConversations({
106
121
  router.add('classic.Stop', async (e, next) => endTurn({ reason: 'answer', ...(e.last_assistant_message === undefined ? {} : { answer: e.last_assistant_message }) })(await next(e)))
107
122
  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
123
  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)))
124
+ router.add('classic.Stop', async (e, next) => {
125
+ const below = await next(e)
126
+ if (below.block === undefined) void answerTask().catch((error: unknown) => report('task', 'Could not record that this session answered its task', error))
127
+ return below
128
+ })
109
129
 
110
130
  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
131
 
@@ -122,6 +142,28 @@ export function serveConversations({
122
142
 
123
143
  const isRunning = async (id: string) => (await json<SessionData[]>(['history', 'sessions', '--running', '--all'])).some((each) => each.id === id)
124
144
 
145
+ let color: SessionColor = { session: '' }
146
+ let onColor: (told: SessionColor) => void = () => undefined
147
+
148
+ const recolor = (id: string, picked: string | undefined) => {
149
+ if (session !== id) return
150
+ color = { session: id, ...(picked === undefined ? {} : { color: picked }) }
151
+ onColor(color)
152
+ }
153
+
154
+ const readColor = (id: string) =>
155
+ void json<SessionData[]>(['history', 'sessions', '--running', '--all'])
156
+ .then((running) => recolor(id, running.find((each) => each.id === id)?.color))
157
+ .catch((error: unknown) => report('color', `Could not read the color of session ${id}`, error))
158
+
159
+ router.add('command.run', async (e, next) => {
160
+ const answer = await next(e)
161
+ if (e.command !== 'color' || session === '') return answer
162
+ if (e.args.trim() === '') readColor(session)
163
+ else recolor(session, pickedColor(e.args))
164
+ return answer
165
+ })
166
+
125
167
  let lastTurn: TurnData | undefined
126
168
  const closings = new Map<string, TurnData>()
127
169
  const dispatch = (to: string, changes: readonly SessionChange[], isInProject = true) => {
@@ -154,7 +196,7 @@ export function serveConversations({
154
196
  follower?.stop()
155
197
  follower = undefined
156
198
  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 } })
199
+ const stream = spawnCmod(claude, ['history', 'follow', ...args, '--json', '--project', project()], session)
158
200
  const started = { sessions: wanted, stop: () => void stream.return(undefined as never) }
159
201
  follower = started
160
202
  void readLines(stream, (line) => {
@@ -241,13 +283,39 @@ export function serveConversations({
241
283
  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
284
  }
243
285
 
286
+ const hear = (caller: string, id: string, list: readonly Listened[]) => {
287
+ const sessions = heard.get(caller) ?? new Map<string, readonly Heard[]>()
288
+ sessions.set(id, list.map(({ event, matcher }) => ({ event, matcher: matcher === undefined ? undefined : receivedMatcher(matcher) })))
289
+ heard.set(caller, sessions)
290
+ const lists = subscribed.get(caller) ?? new Map<string, readonly Listened[]>()
291
+ lists.set(id, list)
292
+ subscribed.set(caller, lists)
293
+ }
294
+
295
+ const saveListeners = (isEnding = false) => {
296
+ const at = session
297
+ const kept = isEnding ? {} : Object.fromEntries([...subscribed].map(([caller, lists]) => [caller, Object.fromEntries(lists)]))
298
+ const next = savingListeners.then(() => (at === '' ? undefined : claude.fs.write(`${listeners}/${at}.json`, JSON.stringify(kept))))
299
+ savingListeners = next.catch((error: unknown) => claude.ui.log(`cmod: saving the mods' listeners failed: ${messageOf(error)}`, { to: 'debug' }))
300
+ return next
301
+ }
302
+
303
+ const restoreListeners = async () => {
304
+ const kept = JSON.parse(await claude.fs.read(`${listeners}/${session}.json`).catch(ifMissing('{}'))) as Readonly<Record<string, Readonly<Record<string, readonly Listened[]>>>>
305
+ for (const [caller, lists] of Object.entries(kept)) if (!subscribed.has(caller)) for (const [id, list] of Object.entries(lists)) hear(caller, id, list)
306
+ }
307
+
244
308
  const readWatchers = async () => {
245
309
  const files = await claude.fs.list(watches).catch(ifMissing([]))
246
310
  const found: Watcher[] = []
247
311
  for (const file of files) {
248
312
  const from = file.name.replace(/\.json$/, '')
249
- if (from === session || !file.name.endsWith('.json')) continue
313
+ if (!file.name.endsWith('.json')) continue
250
314
  const list = JSON.parse(await claude.fs.read(`${watches}/${file.name}`).catch(ifMissing('[]'))) as readonly Watch[]
315
+ if (from === session) {
316
+ for (const watch of list) if (!ownWatches.has(`${watch.mod}\n${watch.target}`)) ownWatches.set(`${watch.mod}\n${watch.target}`, watch)
317
+ continue
318
+ }
251
319
  found.push(...list.filter((watch) => watch.target === session || (watch.target === everySession && watch.project === project())).map((watch) => ({ ...watch, from })))
252
320
  }
253
321
  watchers = found
@@ -299,6 +367,26 @@ export function serveConversations({
299
367
  }
300
368
  }
301
369
 
370
+ const wants = async (event: BeforeEvent) => {
371
+ await Promise.all([starting, watching])
372
+ return deciders(event).length > 0
373
+ }
374
+
375
+ const decide = (): Decide => {
376
+ let endsAt: Promise<number> | undefined
377
+ return (event, change) =>
378
+ foldBefore(
379
+ event,
380
+ change,
381
+ deciders(event).map((watcher) => async (latest: SessionChange) => {
382
+ if (watcher.from === session) return askHere(watcher.mod, event, latest)
383
+ endsAt ??= claude.clock.now().then((now) => now + awayFoldMs)
384
+ return askAway(watcher, event, latest, await endsAt)
385
+ }),
386
+ (error) => report(`before:${event}`, `Passed the ${event} on without a mod's answer`, error),
387
+ )
388
+ }
389
+
302
390
  installBefore({
303
391
  claude,
304
392
  router,
@@ -307,24 +395,8 @@ export function serveConversations({
307
395
  await starting
308
396
  return place()
309
397
  },
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
- },
398
+ wants,
399
+ fold: decide,
328
400
  },
329
401
  })
330
402
 
@@ -336,8 +408,10 @@ export function serveConversations({
336
408
  liveIds.clear()
337
409
  waitingRows.clear()
338
410
  conversation = createConversation(emptyConversation(id))
411
+ const isNew = source === 'startup' || source === 'clear'
412
+ if (!isNew) readColor(id)
339
413
  starting =
340
- source === 'startup' || source === 'clear'
414
+ isNew
341
415
  ? Promise.resolve(dispatch(id, conversation.startContext(new Date().toISOString())))
342
416
  : json<ConversationState>(['history', 'state', id]).then(
343
417
  (state) => {
@@ -345,8 +419,12 @@ export function serveConversations({
345
419
  },
346
420
  (error: unknown) => report('state', `Starts session ${id} without its earlier turns`, error),
347
421
  )
348
- follow()
422
+ const restored = restoreListeners()
423
+ .catch((error: unknown) => report('listeners', "Could not read back the mods' listeners of this session", error))
424
+ .then(follow)
425
+ starting = Promise.all([starting, restored]).then(() => undefined)
349
426
  reloadWatchers()
427
+ 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
428
  if (ownWatches.size === 0) return
351
429
  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
430
  }
@@ -375,6 +453,7 @@ export function serveConversations({
375
453
  })
376
454
  router.add('classic.SessionEnd', async (e, next) => {
377
455
  if (ownWatches.size > 0) await publishWatches(true).catch((error: unknown) => report('before', 'Left its before listeners registered', error))
456
+ if (subscribed.size > 0) await saveListeners(true).catch((error: unknown) => report('follow', 'Left its listeners registered', error))
378
457
  return next(e)
379
458
  })
380
459
 
@@ -407,8 +486,8 @@ export function serveConversations({
407
486
  const spawned = await next(e)
408
487
  if (spawned.deny !== undefined || spawned.agentId === undefined) return spawned
409
488
  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
489
  await starting
490
+ 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
491
  dispatch(session, conversation.startTask(opened))
413
492
  const waiting = waitingRows.get(id) ?? []
414
493
  waitingRows.delete(id)
@@ -445,6 +524,11 @@ export function serveConversations({
445
524
  changeGoal(relay.task, relay.text)
446
525
  return
447
526
  }
527
+ if (relay.kind === 'taskEnd') {
528
+ await starting
529
+ dispatch(session, conversation.endTask(relay.task, relay.task.closedAt ?? new Date().toISOString()))
530
+ return
531
+ }
448
532
  const answered = async (run: () => Promise<unknown>) => {
449
533
  try {
450
534
  await post(relay.from, { kind: 'answer', id: relay.id, value: (await run()) ?? null })
@@ -477,6 +561,9 @@ export function serveConversations({
477
561
  return null
478
562
  }
479
563
  if (action === 'startTask') return spawnTask(claude, input as TaskStartArgs)
564
+ if (action === 'fork') return spawnTask(claude, input as TaskStartArgs, session)
565
+ if (action === 'startBackgroundTask') return startBackground(input as TaskStartArgs)
566
+ if (action === 'clone') return startBackground(input as TaskStartArgs, session)
480
567
  const { name, input: toolInput } = input as ToolCallArgs
481
568
  return resultOf(place(), { name, input: toolInput, cwd: await claude.session.cwd() }, await claude.tool.call({ ...toolInput, tool: name } as never))
482
569
  }
@@ -503,6 +590,48 @@ export function serveConversations({
503
590
  return null
504
591
  }
505
592
 
593
+ const startBackground = async (task: TaskStartArgs, forkedFrom?: string): Promise<TaskData> => {
594
+ if (task.title.trim() === '') throw new Error('a task needs a title.')
595
+ if (task.name !== undefined) throw new Error('a background task takes no name.')
596
+ const fresh: Partial<TaskStartArgs> = forkedFrom === undefined ? task : {}
597
+ 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 }
598
+ const answer = (await wants(TaskStart)) ? await decide()(TaskStart, { type: TaskStart, task: { ...asked, context: conversation.state.context } }) : undefined
599
+ if (answer?.refuse !== undefined) throw new Error(`a beforeTaskStart listener refused the task: ${answer.refuse}`)
600
+ const owner = forkedFrom === undefined ? (answer?.owner ?? {}) : {}
601
+ const folder = owner.cwd ?? fresh.cwd
602
+ const here = await claude.session.cwd()
603
+ const args = [
604
+ 'task',
605
+ 'start',
606
+ '--title',
607
+ answer?.title ?? task.title,
608
+ ...(forkedFrom === undefined ? [] : ['--clone']),
609
+ ...textArg('--agent', owner.agentType ?? fresh.agentType),
610
+ ...textArg('--model', owner.model ?? fresh.model),
611
+ '--cwd',
612
+ folder === undefined ? here : resolve(here, folder),
613
+ '--json',
614
+ '--',
615
+ answer?.prompt ?? task.prompt,
616
+ ]
617
+ const started = JSON.parse(await runCmod(claude, args, session)) as TaskData
618
+ dispatch(session, conversation.startTask(started))
619
+ return started
620
+ }
621
+
622
+ const answerTask = async () => {
623
+ const { env } = await claude.settings.read()
624
+ const asked = isObject(env) ? env[taskVariable] : undefined
625
+ if (typeof asked !== 'string') return
626
+ const answered = `cmod:task-answered:${session}`
627
+ if ((await claude.store.get(answered)) === true) return
628
+ await claude.store.set(answered, true)
629
+ const { session: from, ...task } = JSON.parse(asked) as TaskData & { readonly session: string }
630
+ const ended: TaskData = { ...task, id: session, closedAt: new Date().toISOString() }
631
+ await claude.fs.write(`${backgroundTasks}/${from}/${session}.json`, JSON.stringify(ended))
632
+ 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' }))
633
+ }
634
+
506
635
  const actElsewhere = async (caller: string, target: string, action: Act, input: unknown, task: string | undefined): Promise<unknown> => {
507
636
  await checkAct(caller, action, input)
508
637
  const isLive = await isRunning(target)
@@ -531,12 +660,14 @@ export function serveConversations({
531
660
  Object.values(conversation.state.tasks).map((task) => ({
532
661
  id: task.id,
533
662
  title: task.title,
534
- agentType: task.agentType,
663
+ ...(task.agentType === undefined ? {} : { agentType: task.agentType }),
535
664
  ...(task.model === undefined ? {} : { model: task.model }),
536
665
  openedBy: task.openedBy,
537
666
  openedAt: task.openedAt,
538
667
  ...(task.closedAt === undefined ? {} : { closedAt: task.closedAt }),
539
668
  ...(task.parent === undefined ? {} : { parent: task.parent }),
669
+ ...(task.forkedFrom === undefined ? {} : { forkedFrom: task.forkedFrom }),
670
+ ...(task.isBackground === true ? { isBackground: true as const } : {}),
540
671
  }))
541
672
 
542
673
  const liveContext = (task: unknown): ContextData[] => {
@@ -600,7 +731,9 @@ export function serveConversations({
600
731
  if (id !== session) return saved
601
732
  await starting
602
733
  const kept = liveTasks().filter((task) => isNested || task.parent === request['task'])
603
- return merged(saved, kept, (task) => task.id)
734
+ 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' }), []))
735
+ const background = saved.flatMap(({ id, status }) => (status === undefined ? [] : [{ id, status }]))
736
+ return tasksWithAgents(merged(saved, kept, (task) => task.id), [...running, ...background])
604
737
  },
605
738
  async [conversationMethods.goal](request) {
606
739
  const id = String(request['session'])
@@ -629,10 +762,9 @@ export function serveConversations({
629
762
  if (caller === undefined) throw new Error('cmod hears no caller for this subscription.')
630
763
  const id = String(request['session'])
631
764
  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)
765
+ hear(caller, id, list)
635
766
  follow()
767
+ await saveListeners()
636
768
  return null
637
769
  },
638
770
  async [conversationMethods.watchBefore](request, caller) {
@@ -656,6 +788,10 @@ export function serveConversations({
656
788
 
657
789
  return {
658
790
  turnClosedBy: (rowId) => closings.get(rowId),
791
+ color: () => color,
792
+ onColor(listener) {
793
+ onColor = listener
794
+ },
659
795
  moved() {
660
796
  for (const [key, watch] of ownWatches) ownWatches.set(key, { ...watch, project: project() })
661
797
  void publishWatches().catch((error: unknown) => report('before', 'Kept its before listeners on the project from before', error))
@@ -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.replace(/@[^@]*$/, ''), '')) ? { ...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)