@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.
- package/docs/commands.md +44 -9
- package/docs/index.md +3 -3
- package/docs/install-steps.md +8 -8
- package/docs/jobs.md +2 -1
- package/docs/mod.md +1 -1
- package/docs/options.md +1 -1
- package/docs/permissions.md +1 -1
- package/docs/session.md +46 -13
- package/docs/testing.md +15 -11
- package/docs/ui.md +43 -11
- package/installer.d.ts +3 -2
- package/installer.d.ts.map +1 -1
- package/jobs/program.d.ts +1 -0
- package/jobs/program.d.ts.map +1 -1
- package/jobs/program.js +39 -23
- package/mod.d.ts +3 -2
- package/mod.d.ts.map +1 -1
- package/mod.js +1 -0
- package/package.json +1 -1
- package/records.d.ts +15 -1
- package/records.d.ts.map +1 -1
- package/records.js +39 -1
- package/register.d.ts.map +1 -1
- package/register.js +8 -5
- package/runtime/before.d.ts +6 -2
- package/runtime/before.d.ts.map +1 -1
- package/runtime/before.js +56 -18
- package/runtime/buttons.d.ts +10 -0
- package/runtime/buttons.d.ts.map +1 -0
- package/runtime/buttons.js +12 -0
- package/runtime/conversations.d.ts +3 -0
- package/runtime/conversations.d.ts.map +1 -1
- package/runtime/conversations.js +164 -27
- package/runtime/engine.d.ts +2 -0
- package/runtime/engine.d.ts.map +1 -1
- package/runtime/grants.d.ts +1 -0
- package/runtime/grants.d.ts.map +1 -1
- package/runtime/grants.js +5 -2
- package/runtime/hotkeys.d.ts.map +1 -1
- package/runtime/hotkeys.js +7 -4
- package/runtime/installer.d.ts +10 -6
- package/runtime/installer.d.ts.map +1 -1
- package/runtime/installer.js +106 -48
- package/runtime/lifecycle.d.ts +2 -2
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +144 -42
- package/runtime/pages.d.ts +3 -1
- package/runtime/pages.d.ts.map +1 -1
- package/runtime/pages.js +48 -14
- package/runtime/parts.d.ts +1 -0
- package/runtime/parts.d.ts.map +1 -1
- package/runtime/parts.js +6 -3
- package/runtime/programs.d.ts +1 -0
- package/runtime/programs.d.ts.map +1 -1
- package/runtime/programs.js +4 -1
- package/runtime/sessions.d.ts +10 -3
- package/runtime/sessions.d.ts.map +1 -1
- package/runtime/sessions.js +104 -59
- package/runtime/toaster.d.ts.map +1 -1
- package/runtime/toaster.js +23 -9
- package/runtime/ui.d.ts +22 -4
- package/runtime/ui.d.ts.map +1 -1
- package/runtime/ui.js +161 -59
- package/runtime/watches.d.ts.map +1 -1
- package/runtime/watches.js +18 -4
- package/runtime/widgets.d.ts +7 -5
- package/runtime/widgets.d.ts.map +1 -1
- package/runtime/widgets.js +31 -21
- package/session/context/turn/message/attached-file.d.ts +13 -0
- package/session/context/turn/message/attached-file.d.ts.map +1 -0
- package/session/context/turn/message/attached-file.js +21 -0
- package/session/context/turn/message/message.d.ts +3 -2
- package/session/context/turn/message/message.d.ts.map +1 -1
- package/session/context/turn/message/message.js +20 -6
- package/session/conversation.d.ts +15 -3
- package/session/conversation.d.ts.map +1 -1
- package/session/conversation.js +23 -4
- package/session/goal/task.d.ts +5 -0
- package/session/goal/task.d.ts.map +1 -1
- package/session/session.d.ts +36 -7
- package/session/session.d.ts.map +1 -1
- package/session/session.js +2 -1
- package/src/installer.ts +3 -2
- package/src/jobs/program.ts +41 -21
- package/src/mod.ts +3 -2
- package/src/records.ts +49 -1
- package/src/register.ts +11 -8
- package/src/runtime/before.ts +61 -22
- package/src/runtime/buttons.ts +22 -0
- package/src/runtime/conversations.ts +170 -34
- package/src/runtime/engine.ts +2 -0
- package/src/runtime/grants.ts +6 -2
- package/src/runtime/hotkeys.ts +7 -4
- package/src/runtime/installer.ts +147 -83
- package/src/runtime/lifecycle.ts +149 -48
- package/src/runtime/pages.ts +56 -12
- package/src/runtime/parts.ts +8 -4
- package/src/runtime/programs.ts +5 -1
- package/src/runtime/sessions.ts +106 -59
- package/src/runtime/toaster.ts +27 -9
- package/src/runtime/ui.ts +166 -62
- package/src/runtime/watches.ts +20 -5
- package/src/runtime/widgets.ts +35 -23
- package/src/session/context/turn/message/attached-file.ts +36 -0
- package/src/session/context/turn/message/message.ts +20 -7
- package/src/session/conversation.ts +31 -6
- package/src/session/goal/task.ts +6 -0
- package/src/session/session.ts +18 -7
- package/src/testing/fake-claude.ts +21 -6
- package/src/testing/fake-elements.ts +57 -8
- package/src/testing/fake-sessions.ts +72 -24
- package/src/testing.ts +114 -33
- package/src/ui/areas.ts +1 -0
- package/src/ui/components.ts +14 -8
- package/src/ui/elements.ts +6 -2
- package/src/ui/markdown.ts +3 -3
- package/src/ui/panes.ts +2 -2
- package/src/ui/styles.ts +6 -2
- package/testing/fake-claude.d.ts +3 -0
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +18 -5
- package/testing/fake-elements.d.ts.map +1 -1
- package/testing/fake-elements.js +52 -7
- package/testing/fake-sessions.d.ts +24 -10
- package/testing/fake-sessions.d.ts.map +1 -1
- package/testing/fake-sessions.js +66 -20
- package/testing.d.ts +8 -12
- package/testing.d.ts.map +1 -1
- package/testing.js +113 -27
- package/ui/areas.d.ts +4 -0
- package/ui/areas.d.ts.map +1 -1
- package/ui/components.d.ts +1 -0
- package/ui/components.d.ts.map +1 -1
- package/ui/components.js +11 -7
- package/ui/elements.d.ts +3 -0
- package/ui/elements.d.ts.map +1 -1
- package/ui/elements.js +6 -1
- package/ui/markdown.d.ts +2 -2
- package/ui/markdown.d.ts.map +1 -1
- package/ui/panes.d.ts +2 -1
- package/ui/panes.d.ts.map +1 -1
- package/ui/styles.d.ts +11 -2
- 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 =
|
|
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
|
|
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 (
|
|
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
|
|
311
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
package/src/runtime/engine.ts
CHANGED
|
@@ -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
|
}
|
package/src/runtime/grants.ts
CHANGED
|
@@ -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
|
|
70
|
+
await checkRun(grants, 'process.run', argv)
|
|
67
71
|
return claude.process.run(argv, init)
|
|
68
72
|
},
|
|
69
73
|
spawn(request) {
|
|
70
|
-
const started =
|
|
74
|
+
const started = checkRun(grants, 'process.spawn', request.argv).then(() => claude.process.spawn(request))
|
|
71
75
|
return spawned(started)
|
|
72
76
|
},
|
|
73
77
|
},
|
package/src/runtime/hotkeys.ts
CHANGED
|
@@ -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)
|