@cmodjs/core 1.6.0 → 2.0.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 +7 -7
- package/docs/dependencies.md +1 -1
- package/docs/index.md +12 -11
- package/docs/jobs.md +12 -16
- package/docs/mod.md +111 -36
- package/docs/permissions.md +14 -18
- package/docs/session.md +171 -82
- package/docs/state.md +5 -5
- package/docs/testing.md +57 -41
- package/docs/ui.md +9 -9
- package/jobs/permissions/decide-permission.d.ts.map +1 -1
- package/jobs/permissions/decide-permission.js +1 -1
- package/jobs/prompt.d.ts +1 -3
- package/jobs/prompt.d.ts.map +1 -1
- package/jobs/prompt.js +2 -21
- package/jobs/slash-command.d.ts +1 -1
- package/jobs/slash-command.d.ts.map +1 -1
- package/jobs/slash-command.js +1 -1
- package/jobs/status-line.d.ts +0 -1
- package/jobs/status-line.d.ts.map +1 -1
- package/jobs/status-line.js +3 -4
- package/mod.d.ts +53 -105
- package/mod.d.ts.map +1 -1
- package/mod.js +18 -80
- package/package.json +1 -1
- package/records.d.ts +1 -1
- package/records.js +1 -1
- package/register.d.ts.map +1 -1
- package/register.js +8 -5
- package/runtime/before.d.ts +11 -4
- package/runtime/before.d.ts.map +1 -1
- package/runtime/before.js +131 -67
- package/runtime/claude.d.ts +6 -1
- package/runtime/claude.d.ts.map +1 -1
- package/runtime/conversations.d.ts.map +1 -1
- package/runtime/conversations.js +82 -38
- package/runtime/deadline.d.ts +17 -0
- package/runtime/deadline.d.ts.map +1 -1
- package/runtime/deadline.js +34 -0
- package/runtime/dependencies.d.ts +2 -0
- package/runtime/dependencies.d.ts.map +1 -1
- package/runtime/dependencies.js +12 -0
- package/runtime/grants.d.ts.map +1 -1
- package/runtime/grants.js +13 -8
- package/runtime/hooks.d.ts +15 -11
- package/runtime/hooks.d.ts.map +1 -1
- package/runtime/hooks.js +8 -165
- package/runtime/lifecycle.d.ts +4 -2
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +96 -62
- package/runtime/permission-check.d.ts +15 -0
- package/runtime/permission-check.d.ts.map +1 -0
- package/runtime/permission-check.js +37 -0
- package/runtime/sessions.d.ts +35 -5
- package/runtime/sessions.d.ts.map +1 -1
- package/runtime/sessions.js +304 -82
- package/runtime/state.d.ts.map +1 -1
- package/runtime/state.js +14 -11
- package/runtime/tool-calls.d.ts +0 -1
- package/runtime/tool-calls.d.ts.map +1 -1
- package/runtime/tool-calls.js +0 -19
- package/session/context/turn/message/instruction-file.d.ts +9 -0
- package/session/context/turn/message/instruction-file.d.ts.map +1 -0
- package/session/context/turn/message/instruction-file.js +4 -0
- package/session/context/turn/message/message.d.ts +6 -4
- package/session/context/turn/message/message.d.ts.map +1 -1
- package/session/context/turn/message/message.js +12 -8
- package/session/context/turn/message/sender.d.ts +2 -1
- package/session/context/turn/message/sender.d.ts.map +1 -1
- package/session/context/turn/message/sender.js +4 -1
- package/session/context/turn/message/tool-call.d.ts +5 -0
- package/session/context/turn/message/tool-call.d.ts.map +1 -1
- package/session/context/turn/message/tool-result.d.ts +6 -1
- package/session/context/turn/message/tool-result.d.ts.map +1 -1
- package/session/context/turn/turn.d.ts +7 -0
- package/session/context/turn/turn.d.ts.map +1 -1
- package/session/conversation.d.ts +10 -3
- package/session/conversation.d.ts.map +1 -1
- package/session/conversation.js +14 -5
- package/session/goal/goal.d.ts +1 -1
- package/session/goal/goal.d.ts.map +1 -1
- package/session/goal/task.d.ts +2 -2
- package/session/goal/task.d.ts.map +1 -1
- package/session/session.d.ts +361 -26
- package/session/session.d.ts.map +1 -1
- package/session/session.js +40 -0
- package/src/jobs/permissions/decide-permission.ts +2 -1
- package/src/jobs/prompt.ts +3 -20
- package/src/jobs/slash-command.ts +2 -2
- package/src/jobs/status-line.ts +4 -7
- package/src/mod.ts +52 -147
- package/src/records.ts +1 -1
- package/src/register.ts +8 -5
- package/src/runtime/before.ts +140 -66
- package/src/runtime/claude.ts +5 -1
- package/src/runtime/conversations.ts +75 -37
- package/src/runtime/deadline.ts +38 -0
- package/src/runtime/dependencies.ts +12 -0
- package/src/runtime/grants.ts +11 -7
- package/src/runtime/hooks.ts +24 -163
- package/src/runtime/lifecycle.ts +110 -77
- package/src/runtime/permission-check.ts +47 -0
- package/src/runtime/sessions.ts +334 -91
- package/src/runtime/state.ts +15 -8
- package/src/runtime/tool-calls.ts +0 -19
- package/src/session/context/turn/message/instruction-file.ts +13 -0
- package/src/session/context/turn/message/message.ts +17 -11
- package/src/session/context/turn/message/sender.ts +5 -2
- package/src/session/context/turn/message/tool-call.ts +3 -0
- package/src/session/context/turn/message/tool-result.ts +6 -1
- package/src/session/context/turn/turn.ts +4 -0
- package/src/session/conversation.ts +21 -8
- package/src/session/goal/goal.ts +1 -1
- package/src/session/goal/task.ts +2 -2
- package/src/session/session.ts +227 -24
- package/src/testing/claude-code.ts +23 -0
- package/src/testing/fake-claude.ts +5 -1
- package/src/testing/fake-sessions.ts +38 -24
- package/src/testing.ts +88 -19
- package/testing/claude-code.d.ts +12 -0
- package/testing/claude-code.d.ts.map +1 -0
- package/testing/claude-code.js +10 -0
- package/testing/fake-claude.d.ts +4 -0
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +4 -1
- package/testing/fake-sessions.d.ts +8 -6
- package/testing/fake-sessions.d.ts.map +1 -1
- package/testing/fake-sessions.js +35 -20
- package/testing.d.ts +38 -9
- package/testing.d.ts.map +1 -1
- package/testing.js +58 -8
- package/docs/hooks.md +0 -259
- package/session/context/turn/message/document.d.ts +0 -7
- package/session/context/turn/message/document.d.ts.map +0 -1
- package/session/context/turn/message/document.js +0 -1
- package/src/session/context/turn/message/document.ts +0 -8
package/src/runtime/before.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { Args, Frozen } from 'claude-code'
|
|
2
2
|
import type { Disposable } from '../mod.js'
|
|
3
3
|
import { ContextEnd } from '../session/context/context.js'
|
|
4
|
-
import {
|
|
5
|
-
import { Message, messagesOf, rowOfAppend, withTexts, type Unplaced } from '../session/context/turn/message/message.js'
|
|
4
|
+
import { InstructionFile, isRulePath } from '../session/context/turn/message/instruction-file.js'
|
|
5
|
+
import { instructionPathOf, Message, messagesOf, noFiles, rowOfAppend, withTexts, type Unplaced } from '../session/context/turn/message/message.js'
|
|
6
6
|
import { Note } from '../session/context/turn/message/note.js'
|
|
7
7
|
import { Notice } from '../session/context/turn/message/notice.js'
|
|
8
8
|
import { Prompt } from '../session/context/turn/message/prompt.js'
|
|
@@ -13,22 +13,40 @@ import { ToolCall } from '../session/context/turn/message/tool-call.js'
|
|
|
13
13
|
import { ToolResult } from '../session/context/turn/message/tool-result.js'
|
|
14
14
|
import { TurnEnd } from '../session/context/turn/turn.js'
|
|
15
15
|
import type { ConversationState, SessionChange, TaskData } from '../session/conversation.js'
|
|
16
|
-
import {
|
|
17
|
-
import type
|
|
16
|
+
import { Task, TaskStart } from '../session/goal/task.js'
|
|
17
|
+
import { sessionEvents, type BeforeEvent, type SessionEvent } from '../session/session.js'
|
|
18
18
|
import type { Claude } from './claude.js'
|
|
19
19
|
import { cmodPluginName } from './dependencies.js'
|
|
20
20
|
import type { Router } from './router.js'
|
|
21
21
|
import { toolInputOf } from './tool-calls.js'
|
|
22
|
+
import { resolve } from '../vendor.js'
|
|
22
23
|
|
|
23
|
-
export type Place = Pick<ConversationState, 'session' | 'context' | 'contextOpenedAt' | 'turn' | 'tasks' | 'taskTurns'>
|
|
24
|
+
export type Place = Pick<ConversationState, 'session' | 'context' | 'contextOpenedAt' | 'turn' | 'tasks' | 'taskTurns' | 'openCalls'>
|
|
24
25
|
|
|
25
26
|
export type Decide = (event: BeforeEvent, change: SessionChange) => Promise<Partial<Answers> | undefined>
|
|
26
27
|
|
|
27
|
-
export type Answers = {
|
|
28
|
+
export type Answers = {
|
|
29
|
+
readonly text: string
|
|
30
|
+
readonly output: unknown
|
|
31
|
+
readonly isError: true
|
|
32
|
+
readonly note: string
|
|
33
|
+
readonly title: string
|
|
34
|
+
readonly refuse: string
|
|
35
|
+
readonly input: Readonly<Record<string, unknown>>
|
|
36
|
+
readonly result: string
|
|
37
|
+
readonly owner: { readonly model: string }
|
|
38
|
+
}
|
|
28
39
|
|
|
29
40
|
export type BeforeHooks = { readonly place: () => Promise<Place>; readonly wants: (event: BeforeEvent) => Promise<boolean>; readonly decide: Decide }
|
|
30
41
|
|
|
31
|
-
const appended: readonly BeforeEvent[] = [Note, Reply,
|
|
42
|
+
const appended: readonly BeforeEvent[] = [Note, Reply, Notice]
|
|
43
|
+
|
|
44
|
+
type ClassicFields = Readonly<Record<string, unknown>>
|
|
45
|
+
|
|
46
|
+
const withNote = (below: unknown, note: string | undefined): ClassicFields => {
|
|
47
|
+
const fields = (below ?? {}) as ClassicFields
|
|
48
|
+
return note === undefined ? fields : { ...fields, additionalContext: [...((fields['additionalContext'] as readonly string[] | undefined) ?? []), note] }
|
|
49
|
+
}
|
|
32
50
|
|
|
33
51
|
const relayTag = /^(?:<cross-session-message[^>]*>\s*)?<cmod-relay>([\s\S]*)<\/cmod-relay>(?:\s*<\/cross-session-message>[\s\S]*)?$/
|
|
34
52
|
|
|
@@ -44,28 +62,51 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
|
|
|
44
62
|
const message = (where: Place, unplaced: Unplaced, task: string | undefined, isInTurn = true): SessionChange => {
|
|
45
63
|
const turn = isInTurn ? (task === undefined ? where.turn : where.taskTurns[task]) : undefined
|
|
46
64
|
const context = task === undefined ? where.context : (where.tasks[task]?.context ?? where.context)
|
|
47
|
-
|
|
65
|
+
const call = unplaced.type === ToolResult ? where.openCalls?.[unplaced.callId] : undefined
|
|
66
|
+
return { type: Message, message: { ...unplaced, ...call, session: where.session, context, ...(turn === undefined ? {} : { turn: turn.prompt.id }), ...(task === undefined ? {} : { task }) } as Message }
|
|
67
|
+
}
|
|
68
|
+
const titles = new Map<string, string>()
|
|
69
|
+
const spawned = new Map<string, string>()
|
|
70
|
+
const agentFolders = new Map<string, string>()
|
|
71
|
+
const toolResultHook = (isFailure: boolean) => async (e: Readonly<Record<string, unknown>>, next: (e: never) => Promise<unknown>) => {
|
|
72
|
+
if (!(await wants(ToolResult))) return next(e as never)
|
|
73
|
+
const where = await place()
|
|
74
|
+
const name = String(e['tool_name'])
|
|
75
|
+
const callId = String(e['tool_use_id'])
|
|
76
|
+
const input = (typeof e['tool_input'] === 'object' && e['tool_input'] !== null ? e['tool_input'] : {}) as Readonly<Record<string, unknown>>
|
|
77
|
+
const task = typeof e['agent_id'] === 'string' ? e['agent_id'] : undefined
|
|
78
|
+
const result = { id: callId, at: now(), type: ToolResult, callId, name, input, cwd: String(e['cwd']), files: noFiles, output: isFailure ? e['error'] : e['tool_response'], isError: isFailure } as const
|
|
79
|
+
const answer = await asked(ToolResult, message(where, result, task))
|
|
80
|
+
const below = await next(e as never)
|
|
81
|
+
const rewritten = answer.output === undefined || isFailure ? {} : name.startsWith('mcp__') ? { updatedMCPToolOutput: answer.output } : { updatedToolOutput: answer.output }
|
|
82
|
+
const failed = answer.isError === true && !isFailure ? { block: answer.note ?? `${name} failed.` } : {}
|
|
83
|
+
return { ...withNote(below, answer.isError === true ? undefined : answer.note), ...rewritten, ...failed }
|
|
48
84
|
}
|
|
49
85
|
const added: Disposable[] = [
|
|
50
86
|
router.add('prompt.submit', async (e, next) => {
|
|
51
|
-
if (!(await wants(Prompt))
|
|
87
|
+
if (!(await wants(Prompt))) return next(e)
|
|
52
88
|
const where = await place()
|
|
53
89
|
const answer = await asked(Prompt, message(where, { id: '', at: now(), type: Prompt, text: e.text, from: senderOf(e.origin, false) }, undefined, false))
|
|
54
90
|
if (answer.refuse !== undefined) return { drop: answer.refuse }
|
|
55
|
-
|
|
91
|
+
const text = answer.text ?? e.text
|
|
92
|
+
if (answer.title !== undefined) titles.set(text, answer.title)
|
|
93
|
+
const context = answer.note === undefined ? e.context : [...(e.context ?? []), answer.note]
|
|
94
|
+
const ran = await next({ ...e, text, ...(context === undefined ? {} : { context }) })
|
|
95
|
+
const passed = titles.get(text)
|
|
96
|
+
if (passed !== undefined && ('drop' in ran || ran.text !== text)) {
|
|
97
|
+
titles.delete(text)
|
|
98
|
+
if (!('drop' in ran) && typeof ran.text === 'string') titles.set(ran.text, passed)
|
|
99
|
+
}
|
|
100
|
+
return ran
|
|
56
101
|
}),
|
|
57
|
-
router.add('
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
if (answer.refuse !== undefined) return { text: answer.refuse }
|
|
63
|
-
if (answer.text === undefined || answer.text === typed) return next(e)
|
|
64
|
-
const same = new RegExp(`^/${e.command}(?:\\s+|$)`).exec(answer.text)
|
|
65
|
-
if (same !== null) return next({ ...e, args: answer.text.slice(same[0].length) })
|
|
66
|
-
void claude.prompt.submit({ text: answer.text })
|
|
67
|
-
return {}
|
|
102
|
+
router.add('classic.UserPromptSubmit', async (e, next) => {
|
|
103
|
+
const named = titles.get(e.prompt)
|
|
104
|
+
titles.delete(e.prompt)
|
|
105
|
+
const below = await next(e)
|
|
106
|
+
return named === undefined ? below : { ...below, sessionTitle: named }
|
|
68
107
|
}),
|
|
108
|
+
router.add('classic.PostToolUse', toolResultHook(false) as never),
|
|
109
|
+
router.add('classic.PostToolUseFailure', toolResultHook(true) as never),
|
|
69
110
|
router.add('session.receive', async (e, next) => {
|
|
70
111
|
if (!(await wants(Prompt)) || relayOf(e) !== undefined) return next(e)
|
|
71
112
|
const where = await place()
|
|
@@ -80,10 +121,10 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
|
|
|
80
121
|
return answer.text === undefined ? next(e) : { text: answer.text }
|
|
81
122
|
}),
|
|
82
123
|
router.add('prompt.attachment', async (e, next) => {
|
|
83
|
-
if (!(await wants(
|
|
124
|
+
if (!(await wants(InstructionFile)) || e.type !== 'nested_memory') return next(e)
|
|
84
125
|
const where = await place()
|
|
85
|
-
const path =
|
|
86
|
-
const answer = await asked(
|
|
126
|
+
const path = instructionPathOf(e.text)
|
|
127
|
+
const answer = await asked(InstructionFile, message(where, { id: '', at: now(), type: InstructionFile, path, text: e.text, isRule: isRulePath(path) }, e.agentId))
|
|
87
128
|
if (answer.refuse !== undefined) return { text: null }
|
|
88
129
|
return answer.text === undefined ? next(e) : { text: answer.text }
|
|
89
130
|
}),
|
|
@@ -106,7 +147,8 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
|
|
|
106
147
|
if (!(await wants(ToolCall))) return next(e)
|
|
107
148
|
const where = await place()
|
|
108
149
|
const input = toolInputOf(e)
|
|
109
|
-
const
|
|
150
|
+
const cwd = (e.agentId === undefined ? undefined : agentFolders.get(e.agentId)) ?? (await claude.session.cwd())
|
|
151
|
+
const answer = await asked(ToolCall, message(where, { id: e.tool_use_id, at: now(), type: ToolCall, callId: e.tool_use_id, name: e.tool, input, cwd, files: noFiles }, e.agentId))
|
|
110
152
|
if (answer.refuse !== undefined) return { deny: answer.refuse }
|
|
111
153
|
if (answer.result !== undefined) return { result: answer.result }
|
|
112
154
|
if (answer.input === undefined) return next(e)
|
|
@@ -114,23 +156,46 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
|
|
|
114
156
|
return next({ ...reserved, ...answer.input } as Frozen<Args<'tool.call'>>)
|
|
115
157
|
}),
|
|
116
158
|
router.add('agent.spawn', async (e, next) => {
|
|
117
|
-
|
|
159
|
+
spawned.set(e.tool_use_id, e.prompt)
|
|
160
|
+
const start = async (args: typeof e) => {
|
|
161
|
+
const started = await next(args)
|
|
162
|
+
const folder = args.isTeammate === true ? undefined : (args.cwd ?? (args.parentAgentId === undefined ? undefined : agentFolders.get(args.parentAgentId)))
|
|
163
|
+
if (started.agentId !== undefined && folder !== undefined) agentFolders.set(started.agentId, resolve(await claude.session.cwd(), folder))
|
|
164
|
+
return started
|
|
165
|
+
}
|
|
166
|
+
if (!(await wants(TaskStart))) return start(e)
|
|
118
167
|
const where = await place()
|
|
119
168
|
const task: TaskData = { id: '', title: e.description, agentType: e.subagentType, model: e.model ?? e.parentModel, openedBy: e.tool_use_id, openedAt: now(), ...(e.parentAgentId === undefined ? {} : { parent: e.parentAgentId }) }
|
|
120
169
|
const answer = await asked(TaskStart, { type: TaskStart, task: { ...task, context: where.context } })
|
|
121
|
-
if (answer.refuse !== undefined)
|
|
122
|
-
|
|
170
|
+
if (answer.refuse !== undefined) {
|
|
171
|
+
spawned.delete(e.tool_use_id)
|
|
172
|
+
return { deny: answer.refuse }
|
|
173
|
+
}
|
|
174
|
+
return start(answer.owner === undefined ? e : { ...e, model: answer.owner.model })
|
|
123
175
|
}),
|
|
124
|
-
router.add('classic.
|
|
125
|
-
if (!(await wants(TaskEnd)) && !(await wants(TurnEnd))) return next(e)
|
|
176
|
+
router.add('classic.SubagentStart', async (e, next) => {
|
|
126
177
|
const where = await place()
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
178
|
+
const openedBy = where.tasks[e.agent_id]?.openedBy
|
|
179
|
+
const prompt = openedBy === undefined ? undefined : spawned.get(openedBy)
|
|
180
|
+
if (openedBy !== undefined) spawned.delete(openedBy)
|
|
181
|
+
if (!(await wants(Prompt))) return next(e)
|
|
182
|
+
const open = where.taskTurns[e.agent_id]?.prompt
|
|
183
|
+
const text = open?.text ?? prompt
|
|
184
|
+
if (text === undefined) return next(e)
|
|
185
|
+
const answer = await asked(Prompt, message(where, { id: open?.id ?? '', at: now(), type: Prompt, text, from: open?.from ?? { type: Task } }, e.agent_id, false))
|
|
186
|
+
return withNote(await next(e), answer.note)
|
|
187
|
+
}),
|
|
188
|
+
router.add('classic.SubagentStop', async (e, next) => {
|
|
189
|
+
const stopped = <Answer extends { readonly block?: string }>(answer: Answer) => {
|
|
190
|
+
if (answer.block === undefined) agentFolders.delete(e.agent_id)
|
|
191
|
+
return answer
|
|
192
|
+
}
|
|
193
|
+
if (!(await wants(TurnEnd))) return stopped(await next(e))
|
|
194
|
+
const turn = (await place()).taskTurns[e.agent_id]
|
|
195
|
+
if (turn === undefined) return stopped(await next(e))
|
|
196
|
+
const answer = await asked(TurnEnd, { type: TurnEnd, turn: { ...turn, closedAt: now() } })
|
|
132
197
|
const below = await next(e)
|
|
133
|
-
return refuse === undefined ? below : { ...below, block: refuse }
|
|
198
|
+
return stopped(answer.refuse === undefined ? below : { ...below, block: answer.refuse })
|
|
134
199
|
}),
|
|
135
200
|
router.add('classic.Stop', async (e, next) => {
|
|
136
201
|
if (!(await wants(TurnEnd))) return next(e)
|
|
@@ -154,47 +219,56 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
|
|
|
154
219
|
}
|
|
155
220
|
|
|
156
221
|
export function withAnswer(change: SessionChange, answer: Partial<Answers>): SessionChange {
|
|
222
|
+
if (change.type === Message && answer.output !== undefined && change.message.type === ToolResult) return { ...change, message: { ...change.message, output: answer.output } }
|
|
157
223
|
if (change.type === Message && answer.text !== undefined) return { ...change, message: { ...change.message, text: answer.text } as Message }
|
|
158
224
|
if (change.type === Message && answer.input !== undefined && change.message.type === ToolCall) return { ...change, message: { ...change.message, input: answer.input } }
|
|
159
225
|
if (change.type === TaskStart && answer.owner !== undefined) return { ...change, task: { ...change.task, model: answer.owner.model } }
|
|
160
226
|
return change
|
|
161
227
|
}
|
|
162
228
|
|
|
163
|
-
export
|
|
229
|
+
export const methodOf = (prefix: 'on' | 'before', event: SessionEvent): string => `${prefix}${Object.entries(sessionEvents).find(([, each]) => each === event)?.[0] ?? event}`
|
|
230
|
+
|
|
231
|
+
export const beforeAnswerKeys: Readonly<Record<BeforeEvent, readonly (keyof Answers)[]>> = {
|
|
232
|
+
prompt: ['text', 'note', 'title', 'refuse'],
|
|
233
|
+
note: ['text'],
|
|
234
|
+
skill: ['text'],
|
|
235
|
+
instructionFile: ['text', 'refuse'],
|
|
236
|
+
reply: ['text'],
|
|
237
|
+
toolCall: ['input', 'result', 'refuse'],
|
|
238
|
+
toolResult: ['output', 'isError', 'note'],
|
|
239
|
+
notice: ['text'],
|
|
240
|
+
taskStart: ['owner', 'refuse'],
|
|
241
|
+
turnEnd: ['refuse'],
|
|
242
|
+
contextEnd: ['refuse'],
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function foldBefore(event: BeforeEvent, change: SessionChange, askers: readonly ((change: SessionChange) => Promise<unknown>)[], report: (error: unknown) => void): Promise<Partial<Answers> | undefined> {
|
|
246
|
+
return foldAnswers(methodOf('before', event), beforeAnswerKeys[event], ['refuse', 'result'], change, askers, report, withAnswer)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function takenAnswer(method: string, takes: readonly string[], answer: unknown): unknown {
|
|
250
|
+
if (answer === undefined || answer === null || answer === false) return undefined
|
|
251
|
+
if (typeof answer !== 'object' || Array.isArray(answer)) throw new Error(`it answered ${JSON.stringify(answer)}, but ${method} takes an object or nothing: ${takes.map((key) => `{ ${key} }`).join(', ') || 'no answer'}.`)
|
|
252
|
+
const unread = Object.keys(answer).filter((key) => !takes.includes(key))
|
|
253
|
+
if (unread.length > 0) throw new Error(`it answered ${unread.join(' and ')}, which ${method} does not take. It takes ${takes.map((key) => `{ ${key} }`).join(', ') || 'no answer'}.`)
|
|
254
|
+
return answer
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function foldAnswers<Change, Answer extends object>(method: string, takes: readonly string[], ends: readonly string[], change: Change, askers: readonly ((change: Change) => Promise<unknown>)[], report: (error: unknown) => void, applied: (change: Change, answer: Answer) => Change = (same) => same): Promise<Answer | undefined> {
|
|
164
258
|
let latest = change
|
|
165
|
-
let result:
|
|
259
|
+
let result: Record<string, unknown> | undefined
|
|
166
260
|
for (const ask of askers) {
|
|
167
261
|
try {
|
|
168
|
-
const answer =
|
|
169
|
-
if (answer ===
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
262
|
+
const answer = takenAnswer(method, takes, await ask(latest))
|
|
263
|
+
if (typeof answer !== 'object' || answer === null) continue
|
|
264
|
+
const given = answer as Record<string, unknown>
|
|
265
|
+
const notes = [result?.['note'], given['note']].filter((note) => typeof note === 'string')
|
|
266
|
+
result = { ...result, ...given, ...(notes.length === 0 ? {} : { note: notes.join('\n\n') }) }
|
|
267
|
+
if (ends.some((key) => given[key] !== undefined)) return result as Answer
|
|
268
|
+
latest = applied(latest, answer as Answer)
|
|
173
269
|
} catch (error) {
|
|
174
270
|
report(error)
|
|
175
271
|
}
|
|
176
272
|
}
|
|
177
|
-
return result
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
export function answerOf(event: BeforeEvent, answer: unknown): Partial<Answers> | undefined {
|
|
181
|
-
if (typeof answer !== 'object' || answer === null) return undefined
|
|
182
|
-
const given = answer as Partial<Answers>
|
|
183
|
-
const allowed: Readonly<Record<BeforeEvent, readonly (keyof Answers)[]>> = {
|
|
184
|
-
prompt: ['text', 'refuse'],
|
|
185
|
-
note: ['text'],
|
|
186
|
-
skill: ['text'],
|
|
187
|
-
document: ['text', 'refuse'],
|
|
188
|
-
reply: ['text'],
|
|
189
|
-
toolCall: ['input', 'result', 'refuse'],
|
|
190
|
-
toolResult: ['text'],
|
|
191
|
-
notice: ['text'],
|
|
192
|
-
taskStart: ['owner', 'refuse'],
|
|
193
|
-
taskEnd: ['refuse'],
|
|
194
|
-
turnEnd: ['refuse'],
|
|
195
|
-
contextEnd: ['refuse'],
|
|
196
|
-
}
|
|
197
|
-
const unread = Object.keys(given).filter((key) => !allowed[event].includes(key as keyof Answers))
|
|
198
|
-
if (unread.length > 0) throw new Error(`a before(${event}) listener answered ${unread.join(' and ')}, which ${event} does not take. It takes ${allowed[event].map((key) => `{ ${key} }`).join(', ')}.`)
|
|
199
|
-
return given
|
|
273
|
+
return result as Answer | undefined
|
|
200
274
|
}
|
package/src/runtime/claude.ts
CHANGED
|
@@ -5,6 +5,9 @@ import type {
|
|
|
5
5
|
Args,
|
|
6
6
|
EngineInterface,
|
|
7
7
|
AskOptions,
|
|
8
|
+
CommandInfo,
|
|
9
|
+
CommandRunArgs,
|
|
10
|
+
CommandRunResult,
|
|
8
11
|
CommandSpec,
|
|
9
12
|
ConfigRow,
|
|
10
13
|
ElementTable,
|
|
@@ -93,8 +96,9 @@ export type Claude = {
|
|
|
93
96
|
send: EngineInterface['session']['send']
|
|
94
97
|
}
|
|
95
98
|
readonly prompt: { submit: EngineInterface['prompt']['submit'] }
|
|
99
|
+
readonly turn: { abort: EngineInterface['turn']['abort'] }
|
|
96
100
|
readonly model: { complete: EngineInterface['model']['complete'] }
|
|
97
|
-
readonly command: { register(command: CommandSpec): Promise<{ command: string }> }
|
|
101
|
+
readonly command: { register(command: CommandSpec): Promise<{ command: string }>; list(): Promise<readonly CommandInfo[]>; run(args: CommandRunArgs): Promise<CommandRunResult> }
|
|
98
102
|
readonly tool: { register(tool: ToolSpec): Promise<{ tool: string }>; call: EngineInterface['tool']['call'] }
|
|
99
103
|
readonly agent: {
|
|
100
104
|
list(): Promise<AgentInfo[]>
|
|
@@ -3,18 +3,18 @@ import { consentPath, dataFolder, isObject, parseConsent } from '../records.js'
|
|
|
3
3
|
import { Message, rowOfAppend, type Row } from '../session/context/turn/message/message.js'
|
|
4
4
|
import { 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 } from '../session/conversation.js'
|
|
7
|
-
import { Session, UsageChange, type BeforeEvent, type
|
|
6
|
+
import { createConversation, emptyConversation, type Conversation, type ConversationState, type ContextData, type SessionChange, type SessionData, type TaskData, type TurnData, type TurnEnding } from '../session/conversation.js'
|
|
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 { receivedMatcher } from '../utils/matcher.js'
|
|
10
10
|
import { ifMissing } from '../utils/paths.js'
|
|
11
11
|
import { formatExit, messageOf } from '../utils/text.js'
|
|
12
|
-
import {
|
|
12
|
+
import { foldBefore, installBefore, relayOf, type Place } from './before.js'
|
|
13
13
|
import type { Claude } from './claude.js'
|
|
14
14
|
import { cmodPluginName } from './dependencies.js'
|
|
15
15
|
import { readLines, runCmod } from './programs.js'
|
|
16
16
|
import type { Router } from './router.js'
|
|
17
|
-
import { actPermission,
|
|
17
|
+
import { actPermission, conversationMethods, everySession, isAct, isHeardBy, resultOf, spawnTask, usageOf, type Act, type Subscription } from './sessions.js'
|
|
18
18
|
|
|
19
19
|
type Request = Readonly<Record<string, unknown>>
|
|
20
20
|
|
|
@@ -64,8 +64,23 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
64
64
|
const liveIds = new Set<string>()
|
|
65
65
|
const waitingRows = new Map<string, Row[]>()
|
|
66
66
|
let follower: { readonly sessions: string; stop(): void } | undefined
|
|
67
|
+
const engineTurns = new Map<string, string>()
|
|
68
|
+
router.add('turn.start', async (e, next) => {
|
|
69
|
+
const agent = (e as typeof e & { readonly agentId?: string }).agentId ?? ''
|
|
70
|
+
engineTurns.set(agent, e.turnId)
|
|
71
|
+
return next(e)
|
|
72
|
+
})
|
|
73
|
+
const endTurn = (ending: TurnEnding, task?: string) => <Below extends { readonly block?: string }>(below: Below): Below => {
|
|
74
|
+
if (below.block !== undefined) return below
|
|
75
|
+
engineTurns.delete(task ?? '')
|
|
76
|
+
dispatch(session, conversation.endTurn(new Date().toISOString(), ending, task))
|
|
77
|
+
return below
|
|
78
|
+
}
|
|
79
|
+
router.add('classic.Stop', async (e, next) => endTurn({ reason: 'answer', ...(e.last_assistant_message === undefined ? {} : { answer: e.last_assistant_message }) })(await next(e)))
|
|
80
|
+
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)))
|
|
81
|
+
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)))
|
|
67
82
|
|
|
68
|
-
const json = async <Value>(args: readonly string[]): Promise<Value> => JSON.parse(await runCmod(claude, [...args, '--json', '--project', project()],
|
|
83
|
+
const json = async <Value>(args: readonly string[]): Promise<Value> => JSON.parse(await runCmod(claude, [...args, '--json', '--project', project()], await claude.session.id())) as Value
|
|
69
84
|
|
|
70
85
|
const isGranted = async (caller: string | undefined, item: string) => {
|
|
71
86
|
if (caller === cmodPluginName) return true
|
|
@@ -75,6 +90,7 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
75
90
|
return (parseConsent(JSON.parse(text), path)[caller] ?? []).includes(item)
|
|
76
91
|
}
|
|
77
92
|
|
|
93
|
+
|
|
78
94
|
const ended = (id: string) => new Error(`session ${id} has ended`)
|
|
79
95
|
|
|
80
96
|
const isRunning = async (id: string) => (await json<SessionData[]>(['history', 'sessions', '--running', '--all'])).some((each) => each.id === id)
|
|
@@ -94,6 +110,7 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
94
110
|
}
|
|
95
111
|
|
|
96
112
|
const restartFollower = async () => {
|
|
113
|
+
if (session === '') return
|
|
97
114
|
const wantsEvery = [...heard.values()].some((sessions) => (sessions.get(everySession) ?? []).length > 0)
|
|
98
115
|
const named = [...heard.values()].flatMap((sessions) => [...sessions].filter(([id, list]) => id !== session && id !== everySession && list.length > 0).map(([id]) => id))
|
|
99
116
|
const others = [...new Set(named)].sort()
|
|
@@ -128,17 +145,36 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
128
145
|
if (sent.isDelivered !== true) throw ended(to)
|
|
129
146
|
}
|
|
130
147
|
|
|
148
|
+
const waitingOn = new Map<Asked, string>()
|
|
149
|
+
let liveness: { cancel(): void } | undefined
|
|
150
|
+
let isChecking = false
|
|
151
|
+
const checkLiveness = async () => {
|
|
152
|
+
if (isChecking) return
|
|
153
|
+
isChecking = true
|
|
154
|
+
try {
|
|
155
|
+
const waiting = [...waitingOn]
|
|
156
|
+
const running = new Set((await json<SessionData[]>(['history', 'sessions', '--running', '--all'])).map((each) => each.id))
|
|
157
|
+
for (const [settle, to] of waiting) if (!running.has(to)) settle.reject(ended(to))
|
|
158
|
+
} finally {
|
|
159
|
+
isChecking = false
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const watchLife = (settle: Asked, to: string) => {
|
|
163
|
+
waitingOn.set(settle, to)
|
|
164
|
+
liveness ??= claude.clock.every(livenessMs, () => void checkLiveness().catch((error: unknown) => report('liveness', 'Could not check which sessions still run', error)))
|
|
165
|
+
return {
|
|
166
|
+
cancel() {
|
|
167
|
+
waitingOn.delete(settle)
|
|
168
|
+
if (waitingOn.size > 0) return
|
|
169
|
+
liveness?.cancel()
|
|
170
|
+
liveness = undefined
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
131
175
|
const ask = (to: string, relay: Question, deadlineMs?: number) =>
|
|
132
176
|
new Promise<unknown>((resolve, reject) => {
|
|
133
177
|
const id = `${session}:${(asks += 1)}`
|
|
134
|
-
const timer =
|
|
135
|
-
deadlineMs === undefined
|
|
136
|
-
? claude.clock.every(livenessMs, () => {
|
|
137
|
-
void isRunning(to).then((isLive) => {
|
|
138
|
-
if (!isLive) settle.reject(ended(to))
|
|
139
|
-
})
|
|
140
|
-
})
|
|
141
|
-
: claude.clock.after(deadlineMs, () => settle.reject(new Error(`session ${to} did not answer within ${deadlineMs / 1000} s`)))
|
|
142
178
|
const settle: Asked = {
|
|
143
179
|
resolve(value) {
|
|
144
180
|
if (asked.delete(id)) timer.cancel()
|
|
@@ -149,6 +185,7 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
149
185
|
reject(error)
|
|
150
186
|
},
|
|
151
187
|
}
|
|
188
|
+
const timer = deadlineMs === undefined ? watchLife(settle, to) : claude.clock.after(deadlineMs, () => settle.reject(new Error(`session ${to} did not answer within ${deadlineMs / 1000} s`)))
|
|
152
189
|
asked.set(id, settle)
|
|
153
190
|
post(to, { ...relay, id, from: session }).catch((error: unknown) => settle.reject(error instanceof Error ? error : new Error(messageOf(error))))
|
|
154
191
|
})
|
|
@@ -189,8 +226,8 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
189
226
|
}
|
|
190
227
|
|
|
191
228
|
const place = (): Place => {
|
|
192
|
-
const { context, contextOpenedAt, turn, tasks, taskTurns } = conversation.state
|
|
193
|
-
return { session, context, contextOpenedAt, ...(turn === undefined ? {} : { turn }), tasks, taskTurns }
|
|
229
|
+
const { context, contextOpenedAt, turn, tasks, taskTurns, openCalls } = conversation.state
|
|
230
|
+
return { session, context, contextOpenedAt, ...(turn === undefined ? {} : { turn }), tasks, taskTurns, ...(openCalls === undefined ? {} : { openCalls }) }
|
|
194
231
|
}
|
|
195
232
|
|
|
196
233
|
const deciders = (event: string): Watcher[] => {
|
|
@@ -199,7 +236,7 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
199
236
|
}
|
|
200
237
|
|
|
201
238
|
const askHere = async (mod: string, event: BeforeEvent, change: SessionChange, from = session, isInProject = true) => {
|
|
202
|
-
|
|
239
|
+
if (from !== session && !(await isGranted(mod, 'conversation'))) throw new Error(`${mod} needs the conversation permission to change what happens in another session. Turn it on in /mods ${mod}.`)
|
|
203
240
|
return claude.cmod.call({ to: mod, method: conversationMethods.before, input: { session: from, event, change, isInProject } })
|
|
204
241
|
}
|
|
205
242
|
|
|
@@ -228,7 +265,7 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
228
265
|
return deciders(event).length > 0
|
|
229
266
|
},
|
|
230
267
|
decide: (event, change) =>
|
|
231
|
-
|
|
268
|
+
foldBefore(
|
|
232
269
|
event,
|
|
233
270
|
change,
|
|
234
271
|
deciders(event).map((watcher) => (latest: SessionChange) => (watcher.from === session ? askHere(watcher.mod, event, latest) : askAway(watcher, event, latest))),
|
|
@@ -360,9 +397,10 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
360
397
|
const perform = async (caller: string, action: Act, input: unknown, task: string | undefined): Promise<unknown> => {
|
|
361
398
|
await checkAct(caller, action, input)
|
|
362
399
|
await starting
|
|
400
|
+
if (action === 'abort') return abortTurn(input, task)
|
|
363
401
|
if (task !== undefined) return performInTask(action, input, task)
|
|
364
402
|
if (action === 'usage') return usageOf(claude)
|
|
365
|
-
if (action === '
|
|
403
|
+
if (action === 'surfaces') return [...(await claude.session.surfaces())] satisfies Surface[]
|
|
366
404
|
if (action === 'send') {
|
|
367
405
|
const { type, text, as } = input as SessionSend
|
|
368
406
|
if (as === Session) throw new Error(`session ${session} cannot send as itself to itself.`)
|
|
@@ -371,26 +409,25 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
371
409
|
if ('drop' in delivered && typeof delivered.drop === 'string') throw new Error(`Claude Code dropped the prompt: ${delivered.drop}`)
|
|
372
410
|
return null
|
|
373
411
|
}
|
|
374
|
-
if (action === 'startTask')
|
|
375
|
-
const args = input as TaskStartArgs
|
|
376
|
-
const started = await claude.agent.spawn({
|
|
377
|
-
prompt: args.prompt,
|
|
378
|
-
...(args.name === undefined ? {} : { description: args.name, name: args.name }),
|
|
379
|
-
...(args.agentType === undefined ? {} : { subagentType: args.agentType }),
|
|
380
|
-
...(args.model === undefined ? {} : { model: args.model }),
|
|
381
|
-
...(args.cwd === undefined ? {} : { cwd: args.cwd }),
|
|
382
|
-
})
|
|
383
|
-
if (started.deny !== undefined) throw new Error(`Claude Code refused the task: ${started.deny}`)
|
|
384
|
-
return { id: started.agentId ?? '', title: args.name ?? '', agentType: args.agentType ?? 'general-purpose', model: started.model, openedBy: '', openedAt: new Date().toISOString() } satisfies TaskData
|
|
385
|
-
}
|
|
412
|
+
if (action === 'startTask') return spawnTask(claude, input as TaskStartArgs)
|
|
386
413
|
const { name, input: toolInput } = input as ToolCallArgs
|
|
387
|
-
return resultOf(place(), await claude.tool.call({ ...toolInput, tool: name } as never))
|
|
414
|
+
return resultOf(place(), { name, input: toolInput, cwd: await claude.session.cwd() }, await claude.tool.call({ ...toolInput, tool: name } as never))
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const abortTurn = async (input: unknown, task: string | undefined) => {
|
|
418
|
+
const turnId = engineTurns.get(task ?? '')
|
|
419
|
+
if (turnId === undefined) throw new Error(`no turn is running in ${task === undefined ? `session ${session}` : `task ${task}`}.`)
|
|
420
|
+
const asked = isObject(input) ? input['turn'] : undefined
|
|
421
|
+
const open = task === undefined ? conversation.state.turn : conversation.state.taskTurns[task]
|
|
422
|
+
if (open?.prompt.id !== asked) throw new Error(`turn ${String(asked)} has ended, so there is nothing to abort.`)
|
|
423
|
+
await claude.turn.abort({ turnId })
|
|
424
|
+
return null
|
|
388
425
|
}
|
|
389
426
|
|
|
390
427
|
const performInTask = async (action: Act, input: unknown, task: string): Promise<unknown> => {
|
|
391
428
|
const running = conversation.state.tasks[task]
|
|
392
429
|
if (action === 'usage') return json(['history', 'usage', session, '--task', task])
|
|
393
|
-
if (action === '
|
|
430
|
+
if (action === 'surfaces') return running === undefined || running.closedAt !== undefined ? [] : [...(await claude.session.surfaces())]
|
|
394
431
|
if (action !== 'send') throw new Error(`cmod: ${action} runs only on a session's own main loop.`)
|
|
395
432
|
if (running === undefined || running.closedAt !== undefined) throw ended(task)
|
|
396
433
|
const sent = await claude.session.send({ to: { agentId: task }, text: (input as SessionSend).text })
|
|
@@ -402,7 +439,7 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
402
439
|
await checkAct(caller, action, input)
|
|
403
440
|
const isLive = await isRunning(target)
|
|
404
441
|
if (!isLive && action === 'usage') return json(['history', 'usage', target, ...(task === undefined ? [] : ['--task', task])])
|
|
405
|
-
if (!isLive && action === '
|
|
442
|
+
if (!isLive && action === 'surfaces') return []
|
|
406
443
|
if (!isLive) throw ended(task ?? target)
|
|
407
444
|
if (action === 'send' && task === undefined && (input as SessionSend).as === Session) {
|
|
408
445
|
const sent = await claude.session.send({ to: { sessionId: target }, text: (input as SessionSend).text })
|
|
@@ -452,8 +489,8 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
452
489
|
},
|
|
453
490
|
async [conversationMethods.search](request) {
|
|
454
491
|
const before = isObject(request['before']) ? request['before'] : undefined
|
|
455
|
-
const record = [...textArg('--session', request['session']), ...textArg('--task', request['task']), ...textArg('--turn', request['turn']), ...countArg('--context', request['context']), ...(request['
|
|
456
|
-
return json(['history', 'search', String(request['text']), ...scopeArgs(request), ...record, ...(request['running'] === true ? ['--running'] : []), ...
|
|
492
|
+
const record = [...textArg('--session', request['session']), ...textArg('--task', request['task']), ...textArg('--turn', request['turn']), ...countArg('--context', request['context']), ...(request['isAgent'] === true ? ['--agent'] : [])]
|
|
493
|
+
return json(['history', 'search', String(request['text']), ...scopeArgs(request), ...record, ...(request['running'] === true ? ['--running'] : []), ...(before === undefined ? [] : ['--before', `${String(before['session'])}:${String(before['id'])}`])])
|
|
457
494
|
},
|
|
458
495
|
async [conversationMethods.messages](request) {
|
|
459
496
|
const id = String(request['session'])
|
|
@@ -540,7 +577,7 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
540
577
|
const action = String(request['action']) as Act
|
|
541
578
|
const task = typeof request['task'] === 'string' ? request['task'] : undefined
|
|
542
579
|
if (!isAct(action)) throw new Error(`cmod takes no action named ${action}.`)
|
|
543
|
-
if (target === session) return perform(caller, action, request['input'], task)
|
|
580
|
+
if (target === (await claude.session.id())) return perform(caller, action, request['input'], task)
|
|
544
581
|
return actElsewhere(caller, target, action, request['input'], task)
|
|
545
582
|
},
|
|
546
583
|
}
|
|
@@ -556,7 +593,8 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
556
593
|
const method = methods[e.method]
|
|
557
594
|
if (method === undefined) return undefined
|
|
558
595
|
const input = isObject(e.input) ? e.input : {}
|
|
559
|
-
|
|
596
|
+
const isDropping = (e.method === conversationMethods.subscribe || e.method === conversationMethods.watchBefore) && Array.isArray(input['subscriptions']) && input['subscriptions'].length === 0
|
|
597
|
+
if (e.method !== conversationMethods.act && !isDropping && input['session'] !== (await claude.session.id()) && !(await isGranted(e.caller, 'conversation'))) return { deny: `${e.caller ?? 'A mod'} reads your conversations without your grant. Turn it on in /mods ${e.caller ?? ''}.`.trim() }
|
|
560
598
|
try {
|
|
561
599
|
return { value: await method(input, e.caller) }
|
|
562
600
|
} catch (error) {
|
package/src/runtime/deadline.ts
CHANGED
|
@@ -6,6 +6,44 @@ export const longestMs = 600_000
|
|
|
6
6
|
|
|
7
7
|
export const toolDeadline: Deadline = { ms: 30000, job: 'tool' }
|
|
8
8
|
|
|
9
|
+
export type ListenOptions = { readonly timeoutMs?: number; readonly onFailure?: 'pass' | 'refuse' }
|
|
10
|
+
|
|
11
|
+
export type ListenTime = { readonly deadline: number; readonly signal: AbortSignal }
|
|
12
|
+
|
|
13
|
+
export const listenerMs = 10_000
|
|
14
|
+
|
|
15
|
+
export class PassedTimeout extends Error {}
|
|
16
|
+
|
|
17
|
+
const timers = globalThis as unknown as { setTimeout(run: () => void, ms: number): unknown; clearTimeout(timer: unknown): void }
|
|
18
|
+
|
|
19
|
+
export function listenerOf(name: string, method: string, args: readonly unknown[]): { readonly matcher: unknown; readonly listener: (payload: never, time: ListenTime) => unknown; readonly options: ListenOptions } {
|
|
20
|
+
const hasMatcher = typeof args[1] === 'function'
|
|
21
|
+
const listener = (hasMatcher ? args[1] : args[0]) as (payload: never, time: ListenTime) => unknown
|
|
22
|
+
const options = ((hasMatcher ? args[2] : args[1]) ?? {}) as ListenOptions
|
|
23
|
+
if (typeof listener !== 'function') throw new Error(`${name}: ${method} takes a listener, or a matcher and then a listener.`)
|
|
24
|
+
const { timeoutMs, onFailure } = options as { readonly timeoutMs?: unknown; readonly onFailure?: unknown }
|
|
25
|
+
if (timeoutMs !== undefined && (typeof timeoutMs !== 'number' || !Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > longestMs)) throw new Error(`${name}: ${method}'s timeoutMs is ${String(timeoutMs)}. Give a whole number of milliseconds above 0 and at most ${longestMs}.`)
|
|
26
|
+
if (onFailure !== undefined && onFailure !== 'pass' && onFailure !== 'refuse') throw new Error(`${name}: ${method}'s onFailure is ${String(onFailure)}. Give 'pass' or 'refuse'.`)
|
|
27
|
+
return { matcher: hasMatcher ? args[0] : undefined, listener, options }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function withinTime<Value>(clock: Pick<Claude['clock'], 'now'>, method: string, { timeoutMs = listenerMs }: ListenOptions, run: (time: ListenTime) => Value | Promise<Value>): Promise<Value> {
|
|
31
|
+
const controller = new AbortController()
|
|
32
|
+
const deadline = (await clock.now()) + timeoutMs
|
|
33
|
+
let timer: unknown
|
|
34
|
+
const passed = new Promise<never>((_resolve, reject) => {
|
|
35
|
+
timer = timers.setTimeout(() => {
|
|
36
|
+
controller.abort()
|
|
37
|
+
reject(new PassedTimeout(`${method} passed its ${timeoutMs / 1000} s timeout`))
|
|
38
|
+
}, timeoutMs)
|
|
39
|
+
})
|
|
40
|
+
try {
|
|
41
|
+
return await Promise.race([Promise.resolve().then(() => run({ deadline, signal: controller.signal })), passed])
|
|
42
|
+
} finally {
|
|
43
|
+
timers.clearTimeout(timer)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
9
47
|
export function beforeDeadline<Value>(claude: Claude, { ms, job }: Deadline, call: string, task: Promise<Value>): Promise<Value> {
|
|
10
48
|
return new Promise<Value>((resolve, reject) => {
|
|
11
49
|
const passed = job === undefined ? `its ${ms / 1000} s deadline` : `the ${ms / 1000} s deadline of ${job}`
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Args, CmodDependencies, EventResult, Frozen } from 'claude-code'
|
|
2
2
|
import { messageOf } from '../utils/text.js'
|
|
3
3
|
import type { Claude } from './claude.js'
|
|
4
|
+
import type { Router } from './router.js'
|
|
4
5
|
|
|
5
6
|
export const cmodPluginName = 'cmod'
|
|
6
7
|
|
|
@@ -33,6 +34,17 @@ export function withBareRefusals(claude: Claude): Claude {
|
|
|
33
34
|
return { ...claude, cmod: { call } }
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
export function answeringOwnCalls(claude: Claude, router: Router): Claude {
|
|
38
|
+
const name = claude.plugin.name
|
|
39
|
+
const call: Claude['cmod']['call'] = async (input) => {
|
|
40
|
+
if (input.to !== name) return claude.cmod.call(input)
|
|
41
|
+
const answer = await router.dispatch('cmod.call', { ...input, caller: name } as Frozen<Args<'cmod.call'>>, async () => ({ deny: `${name} has no method ${input.method}.` }))
|
|
42
|
+
if (answer.deny !== undefined) throw new Error(answer.deny)
|
|
43
|
+
return answer.value
|
|
44
|
+
}
|
|
45
|
+
return { ...claude, cmod: { call } }
|
|
46
|
+
}
|
|
47
|
+
|
|
36
48
|
export function notInstalled(name: string): string {
|
|
37
49
|
return `${name} is not installed. Add it with /plugin.`
|
|
38
50
|
}
|