@cmodjs/core 3.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/commands.md +39 -22
- package/docs/dependencies.md +27 -20
- package/docs/index.md +21 -17
- package/docs/install-steps.md +37 -29
- package/docs/jobs.md +25 -52
- package/docs/mod.md +94 -66
- package/docs/options.md +2 -2
- package/docs/permissions.md +7 -5
- package/docs/session.md +14 -12
- package/docs/testing.md +55 -32
- package/docs/ui.md +255 -263
- package/hotkeys.d.ts +22 -0
- package/hotkeys.d.ts.map +1 -0
- package/hotkeys.js +26 -0
- package/installer.d.ts +1 -1
- package/installer.d.ts.map +1 -1
- package/installer.js +11 -6
- package/jobs/check.d.ts +2 -1
- package/jobs/check.d.ts.map +1 -1
- package/jobs/check.js +2 -2
- package/jobs/context.d.ts +2 -1
- package/jobs/context.d.ts.map +1 -1
- package/jobs/context.js +1 -1
- package/jobs/permissions.d.ts +2 -1
- package/jobs/permissions.d.ts.map +1 -1
- package/jobs/permissions.js +2 -2
- package/jobs/program.js +1 -1
- package/jobs/prompt.d.ts +2 -1
- package/jobs/prompt.d.ts.map +1 -1
- package/jobs/prompt.js +20 -13
- package/jobs/schedule.d.ts +2 -1
- package/jobs/schedule.d.ts.map +1 -1
- package/jobs/schedule.js +15 -1
- package/jobs/slash-command.d.ts +6 -2
- package/jobs/slash-command.d.ts.map +1 -1
- package/jobs/slash-command.js +6 -5
- package/jobs/tool.d.ts +6 -4
- package/jobs/tool.d.ts.map +1 -1
- package/jobs/tool.js +3 -3
- package/mod.d.ts +63 -42
- package/mod.d.ts.map +1 -1
- package/mod.js +11 -1
- package/package.json +1 -1
- package/records.d.ts +14 -8
- package/records.d.ts.map +1 -1
- package/records.js +44 -31
- package/register.d.ts.map +1 -1
- package/register.js +1 -2
- package/runtime/announce.d.ts +1 -1
- package/runtime/announce.d.ts.map +1 -1
- package/runtime/announce.js +6 -7
- package/runtime/before.d.ts +2 -2
- package/runtime/before.d.ts.map +1 -1
- package/runtime/before.js +1 -1
- package/runtime/claude.d.ts +0 -1
- package/runtime/claude.d.ts.map +1 -1
- package/runtime/conversations.d.ts +4 -1
- package/runtime/conversations.d.ts.map +1 -1
- package/runtime/conversations.js +42 -14
- package/runtime/dependencies.d.ts.map +1 -1
- package/runtime/dependencies.js +2 -1
- package/runtime/engine.d.ts +5 -0
- package/runtime/engine.d.ts.map +1 -1
- package/runtime/grants.js +2 -2
- package/runtime/hooks.d.ts +1 -1
- package/runtime/hooks.d.ts.map +1 -1
- package/runtime/hotkeys.d.ts +15 -0
- package/runtime/hotkeys.d.ts.map +1 -0
- package/runtime/hotkeys.js +58 -0
- package/runtime/installer.d.ts +5 -2
- package/runtime/installer.d.ts.map +1 -1
- package/runtime/installer.js +23 -12
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +190 -141
- package/runtime/options.js +2 -2
- package/runtime/pages.d.ts +28 -0
- package/runtime/pages.d.ts.map +1 -0
- package/runtime/pages.js +98 -0
- package/runtime/parts.d.ts +14 -0
- package/runtime/parts.d.ts.map +1 -0
- package/runtime/parts.js +63 -0
- package/runtime/permission-check.d.ts +2 -2
- package/runtime/permission-check.d.ts.map +1 -1
- package/runtime/permission-check.js +12 -4
- package/runtime/programs.js +1 -1
- package/runtime/router.d.ts +2 -2
- package/runtime/router.d.ts.map +1 -1
- package/runtime/router.js +1 -1
- package/runtime/sessions.d.ts +10 -9
- package/runtime/sessions.d.ts.map +1 -1
- package/runtime/sessions.js +14 -5
- package/runtime/state.d.ts +2 -1
- package/runtime/state.d.ts.map +1 -1
- package/runtime/state.js +1 -0
- package/runtime/toaster.d.ts +1 -1
- package/runtime/toaster.d.ts.map +1 -1
- package/runtime/toaster.js +10 -4
- package/runtime/ui.d.ts +87 -5
- package/runtime/ui.d.ts.map +1 -1
- package/runtime/ui.js +372 -109
- package/runtime/watches.d.ts +9 -0
- package/runtime/watches.d.ts.map +1 -0
- package/runtime/watches.js +73 -0
- package/runtime/widgets.d.ts +48 -0
- package/runtime/widgets.d.ts.map +1 -0
- package/runtime/widgets.js +219 -0
- package/session/context/turn/message/message.d.ts.map +1 -1
- package/session/context/turn/message/message.js +7 -2
- package/session/session.d.ts +11 -5
- package/session/session.d.ts.map +1 -1
- package/src/hotkeys.ts +51 -0
- package/src/installer.ts +8 -4
- package/src/jobs/check.ts +4 -3
- package/src/jobs/context.ts +3 -2
- package/src/jobs/permissions.ts +4 -3
- package/src/jobs/program.ts +1 -1
- package/src/jobs/prompt.ts +23 -14
- package/src/jobs/schedule.ts +16 -2
- package/src/jobs/slash-command.ts +10 -7
- package/src/jobs/tool.ts +7 -5
- package/src/mod.ts +59 -47
- package/src/records.ts +50 -31
- package/src/register.ts +2 -3
- package/src/runtime/announce.ts +6 -7
- package/src/runtime/before.ts +4 -4
- package/src/runtime/claude.ts +0 -1
- package/src/runtime/conversations.ts +66 -20
- package/src/runtime/dependencies.ts +2 -1
- package/src/runtime/engine.ts +5 -0
- package/src/runtime/grants.ts +2 -2
- package/src/runtime/hooks.ts +1 -0
- package/src/runtime/hotkeys.ts +80 -0
- package/src/runtime/installer.ts +24 -12
- package/src/runtime/lifecycle.ts +189 -136
- package/src/runtime/options.ts +2 -2
- package/src/runtime/pages.ts +127 -0
- package/src/runtime/parts.ts +72 -0
- package/src/runtime/permission-check.ts +13 -5
- package/src/runtime/programs.ts +1 -1
- package/src/runtime/router.ts +3 -3
- package/src/runtime/sessions.ts +24 -15
- package/src/runtime/state.ts +3 -1
- package/src/runtime/toaster.ts +10 -4
- package/src/runtime/ui.ts +405 -115
- package/src/runtime/watches.ts +75 -0
- package/src/runtime/widgets.ts +287 -0
- package/src/session/context/turn/message/message.ts +7 -2
- package/src/session/session.ts +10 -6
- package/src/testing/fake-claude.ts +9 -6
- package/src/testing/fake-elements.ts +7 -4
- package/src/testing/fake-sessions.ts +6 -4
- package/src/testing.ts +203 -46
- package/src/ui/areas.ts +27 -0
- package/src/ui/elements.ts +35 -0
- package/src/ui/markdown.ts +18 -18
- package/src/ui/panes.ts +73 -0
- package/src/ui/styles.ts +53 -0
- package/src/utils/subscription.ts +1 -0
- package/testing/fake-claude.d.ts +0 -1
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +9 -5
- package/testing/fake-elements.js +7 -4
- package/testing/fake-sessions.d.ts +2 -1
- package/testing/fake-sessions.d.ts.map +1 -1
- package/testing/fake-sessions.js +1 -0
- package/testing.d.ts +21 -6
- package/testing.d.ts.map +1 -1
- package/testing.js +206 -45
- package/ui/areas.d.ts +29 -0
- package/ui/areas.d.ts.map +1 -0
- package/ui/areas.js +6 -0
- package/ui/elements.d.ts +3 -0
- package/ui/elements.d.ts.map +1 -1
- package/ui/elements.js +36 -0
- package/ui/markdown.d.ts +14 -14
- package/ui/markdown.d.ts.map +1 -1
- package/ui/markdown.js +11 -11
- package/ui/panes.d.ts +71 -0
- package/ui/panes.d.ts.map +1 -0
- package/ui/panes.js +16 -0
- package/ui/styles.d.ts +44 -0
- package/ui/styles.d.ts.map +1 -0
- package/ui/styles.js +24 -0
- package/utils/subscription.d.ts +4 -0
- package/utils/subscription.d.ts.map +1 -0
- package/jobs/status-line.d.ts +0 -6
- package/jobs/status-line.d.ts.map +0 -1
- package/jobs/status-line.js +0 -39
- package/src/jobs/status-line.ts +0 -44
- package/src/ui/define-pane.ts +0 -27
- package/src/ui/places.ts +0 -49
- package/src/utils/disposable.ts +0 -3
- package/ui/define-pane.d.ts +0 -15
- package/ui/define-pane.d.ts.map +0 -1
- package/ui/define-pane.js +0 -12
- package/ui/places.d.ts +0 -43
- package/ui/places.d.ts.map +0 -1
- package/ui/places.js +0 -31
- package/utils/disposable.d.ts +0 -4
- package/utils/disposable.d.ts.map +0 -1
- /package/utils/{disposable.js → subscription.js} +0 -0
package/src/runtime/options.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PluginOptions } from 'claude-code'
|
|
2
2
|
import { fitsOption, isUnset, type Option, type Options } from '../options.js'
|
|
3
|
-
import { configFolders } from '../records.js'
|
|
3
|
+
import { configFolders, secretFix } from '../records.js'
|
|
4
4
|
import { listed, messageOf } from '../utils/text.js'
|
|
5
5
|
import type { Claude } from './claude.js'
|
|
6
6
|
import type { Failure } from './toaster.js'
|
|
@@ -72,7 +72,7 @@ export function createOptions({
|
|
|
72
72
|
for (const [key, value] of Object.entries(file)) {
|
|
73
73
|
const option = declared[key]
|
|
74
74
|
if (option === undefined) ignore(`sets ${key}, which ${name} does not declare. ${Object.keys(declared).length === 0 ? 'Remove it.' : `Remove it, or use one of: ${Object.keys(declared).join(', ')}.`}`)
|
|
75
|
-
else if (option.type === 'secret') ignore(`sets ${key}, a secret, and a project file is committed for everyone to read. Remove it
|
|
75
|
+
else if (option.type === 'secret') ignore(`sets ${key}, a secret, and a project file is committed for everyone to read. Remove it. ${secretFix}`)
|
|
76
76
|
else {
|
|
77
77
|
const misfit = fitsOption(option, value)
|
|
78
78
|
if (misfit === undefined) kept[key] = value
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { RenderElement } from 'claude-code'
|
|
2
|
+
import { isObject } from '../records.js'
|
|
3
|
+
import { messageOf } from '../utils/text.js'
|
|
4
|
+
import type { Claude } from './claude.js'
|
|
5
|
+
import type { DrawnParts } from './parts.js'
|
|
6
|
+
import type { Router } from './router.js'
|
|
7
|
+
import { partMethods, type PageDeclaration, type PageRequest, type PartPress, type PartsUpdate, type ShownPages } from './ui.js'
|
|
8
|
+
import type { Widgets } from './widgets.js'
|
|
9
|
+
|
|
10
|
+
const askedWaitMs = 1000
|
|
11
|
+
|
|
12
|
+
export type ModPages = { readonly mod: string; readonly pages: readonly PageDeclaration[] }
|
|
13
|
+
|
|
14
|
+
export type PageApply = (mod: string, requests: readonly PageRequest[]) => Promise<void>
|
|
15
|
+
|
|
16
|
+
export type Pages = {
|
|
17
|
+
declared(): readonly ModPages[]
|
|
18
|
+
element(mod: string, page: string, params: unknown, columns: number, isFocused: boolean): RenderElement | undefined
|
|
19
|
+
press(mod: string, press: PartPress): void
|
|
20
|
+
update(mod: string, update: PartsUpdate): boolean
|
|
21
|
+
tell(mod: string, shown: ShownPages): void
|
|
22
|
+
apply(listener: PageApply): void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function pagesOf(input: unknown): PageDeclaration[] {
|
|
26
|
+
const listed = isObject(input) && Array.isArray(input['pages']) ? (input['pages'] as unknown[]) : []
|
|
27
|
+
return listed.filter(isObject).flatMap((each) => (typeof each['id'] === 'string' && typeof each['title'] === 'string' && (each['place'] === 'sidebar' || each['place'] === 'settings') ? [{ id: each['id'], title: each['title'], place: each['place'] }] : []))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function servePages({ claude, router, report, widgets, parts }: { readonly claude: Claude; readonly router: Router; readonly report: (id: string, message: string, error: unknown) => void; readonly widgets: Pick<Widgets, 'update'>; readonly parts: DrawnParts }): Pages {
|
|
31
|
+
const declared = new Map<string, readonly PageDeclaration[]>()
|
|
32
|
+
const asking = new Map<string, Promise<void>>()
|
|
33
|
+
const pressing = new Set<Promise<void>>()
|
|
34
|
+
let waiting: { readonly mod: string; readonly requests: readonly PageRequest[] }[] = []
|
|
35
|
+
let inputDepth = 0
|
|
36
|
+
let applyTo: PageApply = async () => undefined
|
|
37
|
+
|
|
38
|
+
const run = (mod: string, requests: readonly PageRequest[]) =>
|
|
39
|
+
applyTo(mod, requests).catch((error: unknown) => report(`pages:${mod}`, `Could not show the page ${mod} asked for`, error))
|
|
40
|
+
|
|
41
|
+
const takeWaiting = async () => {
|
|
42
|
+
const taken = waiting
|
|
43
|
+
waiting = []
|
|
44
|
+
for (const { mod, requests } of taken) await run(mod, requests)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const settle = async () => {
|
|
48
|
+
while (pressing.size > 0) await Promise.allSettled(pressing)
|
|
49
|
+
await takeWaiting()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const wrapped = async <Answer>(next: () => Promise<Answer>): Promise<Answer> => {
|
|
53
|
+
inputDepth += 1
|
|
54
|
+
try {
|
|
55
|
+
const answer = await next()
|
|
56
|
+
await settle()
|
|
57
|
+
return answer
|
|
58
|
+
} finally {
|
|
59
|
+
inputDepth -= 1
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
router.add('command.run', (e, next) => wrapped(async () => (waiting.length > 0 ? {} : next(e))))
|
|
64
|
+
router.add('ui.press', (e, next) => wrapped(() => next(e)))
|
|
65
|
+
|
|
66
|
+
const ask = (mod: string) => {
|
|
67
|
+
const pending =
|
|
68
|
+
asking.get(mod) ??
|
|
69
|
+
claude.cmod
|
|
70
|
+
.call({ to: mod, method: partMethods.list, input: null })
|
|
71
|
+
.then(
|
|
72
|
+
(parts) => void declared.set(mod, pagesOf(parts)),
|
|
73
|
+
(error: unknown) => claude.ui.log(`cmod: ${mod} did not list its pages: ${messageOf(error)}`, { to: 'debug' }),
|
|
74
|
+
)
|
|
75
|
+
.finally(() => asking.delete(mod))
|
|
76
|
+
asking.set(mod, pending)
|
|
77
|
+
return pending
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const update = (mod: string, { parts: listing, redraw, requests, isAsked }: PartsUpdate) => {
|
|
81
|
+
if (listing !== undefined) declared.set(mod, pagesOf(listing))
|
|
82
|
+
if (listing !== undefined || redraw === true) widgets.update(mod, { ...(listing === undefined ? {} : { parts: listing }), ...(redraw === undefined ? {} : { redraw }) })
|
|
83
|
+
if (requests === undefined || requests.length === 0) return false
|
|
84
|
+
if (isAsked !== true) return (void run(mod, requests), false)
|
|
85
|
+
waiting.push({ mod, requests })
|
|
86
|
+
if (inputDepth > 0) return true
|
|
87
|
+
claude.clock.after(askedWaitMs, () => void takeWaiting())
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const press = (mod: string, input: PartPress) => {
|
|
92
|
+
const pending = claude.cmod
|
|
93
|
+
.call({ to: mod, method: partMethods.press, input })
|
|
94
|
+
.then(
|
|
95
|
+
(answer) => void update(mod, (isObject(answer) ? answer : {}) as PartsUpdate),
|
|
96
|
+
(error: unknown) => {
|
|
97
|
+
parts.forget(mod)
|
|
98
|
+
claude.ui.invalidate('ui.render')
|
|
99
|
+
report(`press:${mod}`, `${mod} did not take the press`, error)
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
.finally(() => pressing.delete(pending))
|
|
103
|
+
pressing.add(pending)
|
|
104
|
+
if (inputDepth === 0) void pending.then(takeWaiting)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const cacheKey = (page: string, params: unknown, columns: number, isFocused: boolean) => `${page}\u0000${JSON.stringify(params ?? null)}\u0000${columns}\u0000${isFocused}`
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
declared: () => [...declared].map(([mod, pages]) => ({ mod, pages })),
|
|
111
|
+
element(mod, page, params, columns, isFocused) {
|
|
112
|
+
if (!declared.has(mod)) void ask(mod)
|
|
113
|
+
const cached = cacheKey(page, params, columns, isFocused)
|
|
114
|
+
const name = `The ${page} page of ${mod}`
|
|
115
|
+
void parts.fill(mod, cached, { page, params: params ?? null, columns, isFocused }, name)?.then(() => claude.ui.invalidate('ui.render'))
|
|
116
|
+
return parts.element(mod, cached, `${mod}/${page}:`, name, `page:${mod}/${page}`)
|
|
117
|
+
},
|
|
118
|
+
press,
|
|
119
|
+
update,
|
|
120
|
+
tell(mod, shown) {
|
|
121
|
+
void claude.cmod.call({ to: mod, method: partMethods.shown, input: shown }).catch((error: unknown) => claude.ui.log(`cmod: ${mod} did not hear which of its pages show: ${messageOf(error)}`, { to: 'debug' }))
|
|
122
|
+
},
|
|
123
|
+
apply(listener) {
|
|
124
|
+
applyTo = listener
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { RenderElement } from 'claude-code'
|
|
2
|
+
import { elementOf } from '../ui/elements.js'
|
|
3
|
+
import { messageOf } from '../utils/text.js'
|
|
4
|
+
import type { Claude } from './claude.js'
|
|
5
|
+
import { partMethods, type DrawnPart, type PartPress } from './ui.js'
|
|
6
|
+
|
|
7
|
+
export type DrawnParts = {
|
|
8
|
+
fill(mod: string, cached: string, input: Readonly<Record<string, unknown>>, name: string): Promise<void> | undefined
|
|
9
|
+
element(mod: string, cached: string, keyPrefix: string, name: string, reportId: string): RenderElement | undefined
|
|
10
|
+
forget(mod: string): void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type Drawn = { readonly data: DrawnPart | null; readonly generation: number }
|
|
14
|
+
|
|
15
|
+
const keptDrawings = 200
|
|
16
|
+
|
|
17
|
+
export function drawnParts({ claude, report, press }: { readonly claude: Claude; readonly report: (id: string, message: string, error: unknown) => void; readonly press: (mod: string, press: PartPress) => void }): DrawnParts {
|
|
18
|
+
const drawn = new Map<string, Drawn>()
|
|
19
|
+
const drawing = new Map<string, Promise<void>>()
|
|
20
|
+
const generations = new Map<string, number>()
|
|
21
|
+
const keyOf = (mod: string, cached: string) => `${mod}\u0000${cached}`
|
|
22
|
+
const generationOf = (mod: string) => generations.get(mod) ?? 0
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
fill(mod, cached, input, name) {
|
|
26
|
+
const key = keyOf(mod, cached)
|
|
27
|
+
const generation = generationOf(mod)
|
|
28
|
+
if (drawn.get(key)?.generation === generation) return undefined
|
|
29
|
+
const keep = (data: DrawnPart | null) => {
|
|
30
|
+
drawn.delete(key)
|
|
31
|
+
drawn.set(key, { data, generation })
|
|
32
|
+
const [oldest] = drawn.keys()
|
|
33
|
+
if (drawn.size > keptDrawings && oldest !== undefined) drawn.delete(oldest)
|
|
34
|
+
if (generationOf(mod) !== generation) claude.ui.invalidate('ui.render')
|
|
35
|
+
}
|
|
36
|
+
const pending =
|
|
37
|
+
drawing.get(key) ??
|
|
38
|
+
claude.cmod
|
|
39
|
+
.call({ to: mod, method: partMethods.draw, input })
|
|
40
|
+
.then(
|
|
41
|
+
(data) => keep((data ?? null) as DrawnPart | null),
|
|
42
|
+
(error: unknown) => {
|
|
43
|
+
claude.ui.log(`cmod: ${name} drew nothing: ${messageOf(error)}`, { to: 'debug' })
|
|
44
|
+
keep(null)
|
|
45
|
+
},
|
|
46
|
+
)
|
|
47
|
+
.finally(() => drawing.delete(key))
|
|
48
|
+
drawing.set(key, pending)
|
|
49
|
+
return pending
|
|
50
|
+
},
|
|
51
|
+
element(mod, cached, keyPrefix, name, reportId) {
|
|
52
|
+
const key = keyOf(mod, cached)
|
|
53
|
+
const entry = drawn.get(key)
|
|
54
|
+
if (entry === undefined) return undefined
|
|
55
|
+
drawn.delete(key)
|
|
56
|
+
drawn.set(key, entry)
|
|
57
|
+
const { data } = entry
|
|
58
|
+
if (data === null || data.tree === null) return undefined
|
|
59
|
+
try {
|
|
60
|
+
return elementOf(data.tree, keyPrefix, (handler, args) => press(mod, { draw: data.draw, handler, args })) as RenderElement
|
|
61
|
+
} catch (error) {
|
|
62
|
+
report(reportId, `${name} could not draw`, error)
|
|
63
|
+
return undefined
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
forget(mod) {
|
|
67
|
+
const superseded = generationOf(mod)
|
|
68
|
+
generations.set(mod, superseded + 1)
|
|
69
|
+
for (const [key, { generation }] of drawn) if (key.startsWith(`${mod}\u0000`) && generation < superseded) drawn.delete(key)
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Subscription } from '../utils/subscription.js'
|
|
2
2
|
import type { HookFields } from './hooks.js'
|
|
3
3
|
import { defined, processPayloadOf, recordOf, type HookOn, type RouteOn, type ToolCheck } from './sessions.js'
|
|
4
4
|
|
|
@@ -10,12 +10,14 @@ export type PermissionUse = {
|
|
|
10
10
|
readonly current: () => { readonly id: string; readonly cwd: string }
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export type PermissionRoutes = (method: string, use: PermissionUse) =>
|
|
13
|
+
export type PermissionRoutes = (method: string, use: PermissionUse) => Subscription
|
|
14
14
|
|
|
15
15
|
type Verdict = { readonly decision: 'allow' | 'ask' | 'deny'; readonly reason?: string; readonly rule?: string }
|
|
16
16
|
|
|
17
17
|
const verdictName = (decision: Verdict['decision']) => (decision === 'deny' ? 'refuse' : decision)
|
|
18
18
|
|
|
19
|
+
type BuildFlags = { readonly cmodWithoutApprove?: true }
|
|
20
|
+
|
|
19
21
|
export const permissionRoutes: PermissionRoutes = (method, { raise, refusalFor, hookOn, routeOn, current }) => {
|
|
20
22
|
const isBefore = method.startsWith('before')
|
|
21
23
|
if (method.endsWith('PermissionCheck')) {
|
|
@@ -39,8 +41,11 @@ export const permissionRoutes: PermissionRoutes = (method, { raise, refusalFor,
|
|
|
39
41
|
const answer = await raise(method, payload)
|
|
40
42
|
if (answer === undefined || below.decision === 'deny') return below
|
|
41
43
|
if (typeof answer['refuse'] === 'string') return { decision: 'deny', reason: answer['refuse'] }
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
+
if (!(globalThis as BuildFlags).cmodWithoutApprove) {
|
|
45
|
+
if (typeof answer['ask'] === 'string') return { decision: 'ask', reason: answer['ask'] }
|
|
46
|
+
if (answer['allow'] === true) return { decision: 'allow' }
|
|
47
|
+
}
|
|
48
|
+
return below
|
|
44
49
|
},
|
|
45
50
|
isBefore ? failed : undefined,
|
|
46
51
|
)
|
|
@@ -57,7 +62,10 @@ export const permissionRoutes: PermissionRoutes = (method, { raise, refusalFor,
|
|
|
57
62
|
const answer = await raise(method, payload)
|
|
58
63
|
if (answer === undefined) return undefined
|
|
59
64
|
if (typeof answer['refuse'] === 'string') return refused(answer['refuse'], answer['interrupt'])
|
|
60
|
-
|
|
65
|
+
if (!(globalThis as BuildFlags).cmodWithoutApprove) {
|
|
66
|
+
if (answer['allow'] === true) return { decision: defined({ behavior: 'allow', updatedInput: answer['input'], updatedPermissions: answer['updates'] }) }
|
|
67
|
+
}
|
|
68
|
+
return undefined
|
|
61
69
|
},
|
|
62
70
|
isBefore
|
|
63
71
|
? (input, error) => {
|
package/src/runtime/programs.ts
CHANGED
|
@@ -47,7 +47,7 @@ export function locatingPrograms(claude: Claude): Programs {
|
|
|
47
47
|
let names: ReadonlySet<string> = new Set()
|
|
48
48
|
const located = (argv: readonly string[]): readonly string[] => {
|
|
49
49
|
const [name, ...rest] = argv
|
|
50
|
-
return folder !== undefined && name !== undefined && names.has(name) ? [`${folder}/${name}`, ...rest] : argv
|
|
50
|
+
return folder !== undefined && name !== undefined && (name === 'cmod' || names.has(name)) ? [`${folder}/${name}`, ...rest] : argv
|
|
51
51
|
}
|
|
52
52
|
return {
|
|
53
53
|
claude: {
|
package/src/runtime/router.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Args, EventResult, Frozen } from 'claude-code'
|
|
2
|
-
import type {
|
|
2
|
+
import type { Subscription } from '../utils/subscription.js'
|
|
3
3
|
import type { RoutedEvent } from './hooks.js'
|
|
4
4
|
|
|
5
5
|
export type RouterNext<N extends RoutedEvent> = (e: Frozen<Args<N>>) => Promise<EventResult<N>>
|
|
@@ -9,7 +9,7 @@ export type RoutedHook<N extends RoutedEvent> = (e: Frozen<Args<N>>, next: Route
|
|
|
9
9
|
export type RoutedFailure<N extends RoutedEvent> = (e: Frozen<Args<N>>, error: Error, next: RouterNext<N>) => EventResult<N> | undefined | Promise<EventResult<N> | undefined>
|
|
10
10
|
|
|
11
11
|
export type Router = {
|
|
12
|
-
add<N extends RoutedEvent>(event: N, hook: RoutedHook<N>, failed?: RoutedFailure<N>):
|
|
12
|
+
add<N extends RoutedEvent>(event: N, hook: RoutedHook<N>, failed?: RoutedFailure<N>): Subscription
|
|
13
13
|
dispatch<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>>
|
|
14
14
|
dispatchFailure<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, error: Error, next: RouterNext<N>): Promise<EventResult<N>>
|
|
15
15
|
}
|
|
@@ -24,7 +24,7 @@ export function createRouter(): Router {
|
|
|
24
24
|
const route = { hook, ...(failed === undefined ? {} : { failed }) } as Route
|
|
25
25
|
routes.set(event, [...(routes.get(event) ?? []), route])
|
|
26
26
|
return {
|
|
27
|
-
|
|
27
|
+
remove() {
|
|
28
28
|
const chain = (routes.get(event) ?? []).filter((each) => each !== route)
|
|
29
29
|
if (chain.length === 0) routes.delete(event)
|
|
30
30
|
else routes.set(event, chain)
|
package/src/runtime/sessions.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Args, CommandRunResult, SessionCompactResult } from 'claude-code'
|
|
2
|
-
import type {
|
|
2
|
+
import type { Subscription } from '../utils/subscription.js'
|
|
3
3
|
import { ContextEnd, ContextStart, type Context } from '../session/context/context.js'
|
|
4
4
|
import { Message, noFiles } from '../session/context/turn/message/message.js'
|
|
5
5
|
import { modNote, Note } from '../session/context/turn/message/note.js'
|
|
@@ -16,11 +16,12 @@ import { beforeAnswerKeys, foldAnswers, foldBefore, methodOf, takenAnswer, type
|
|
|
16
16
|
import type { Claude } from './claude.js'
|
|
17
17
|
import { cmodPluginName } from './dependencies.js'
|
|
18
18
|
import { messageOf } from '../utils/text.js'
|
|
19
|
+
import { grantFix, permissionWords } from '../records.js'
|
|
19
20
|
import { listenerOf, PassedTimeout, withinTime, type ListenOptions, type ListenTime } from './deadline.js'
|
|
20
21
|
import { callFiles, type HomeFolders, type HookFields } from './hooks.js'
|
|
21
22
|
import type { PermissionRoutes } from './permission-check.js'
|
|
22
23
|
|
|
23
|
-
export type
|
|
24
|
+
export type Listened = { readonly event: SessionEvent; readonly matcher: SentMatcher | undefined; readonly onFailure?: 'refuse'; readonly scope?: Scope }
|
|
24
25
|
|
|
25
26
|
export const everySession = '*'
|
|
26
27
|
|
|
@@ -33,7 +34,6 @@ export const conversationMethods = {
|
|
|
33
34
|
tasks: 'cmod:tasks',
|
|
34
35
|
goal: 'cmod:goal',
|
|
35
36
|
updateGoal: 'cmod:updateGoal',
|
|
36
|
-
usage: 'cmod:usage',
|
|
37
37
|
subscribe: 'cmod:subscribe',
|
|
38
38
|
watchBefore: 'cmod:watchBefore',
|
|
39
39
|
act: 'cmod:act',
|
|
@@ -69,7 +69,8 @@ export type SessionsClient = {
|
|
|
69
69
|
answerBefore(session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean): Promise<unknown>
|
|
70
70
|
failedBefore(session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean, error: Error): { readonly refuse: string } | undefined
|
|
71
71
|
raise(method: string, payload: Readonly<Record<string, unknown>>): Promise<unknown>
|
|
72
|
-
listen(method: string, args: readonly unknown[]):
|
|
72
|
+
listen(method: string, args: readonly unknown[]): Subscription
|
|
73
|
+
turn(data: TurnData): Turn
|
|
73
74
|
/** An empty title keeps the title the session already has. */
|
|
74
75
|
follow(current: Omit<SessionData, 'isRunning' | 'lastAt'>): void
|
|
75
76
|
}
|
|
@@ -138,7 +139,7 @@ export function withoutPermissionCheck(name: string, hearer: string): string {
|
|
|
138
139
|
return `${name}: ${hearer} hears Claude Code's permission check, so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod.`
|
|
139
140
|
}
|
|
140
141
|
|
|
141
|
-
export type HookOn = (hook: string, listener: (input: unknown) => HookFields | undefined | Promise<HookFields | undefined>, failed?: (input: unknown, error: Error) => HookFields | undefined) =>
|
|
142
|
+
export type HookOn = (hook: string, listener: (input: unknown) => HookFields | undefined | Promise<HookFields | undefined>, failed?: (input: unknown, error: Error) => HookFields | undefined) => Subscription
|
|
142
143
|
|
|
143
144
|
export function createSessionsClient({ name, claude, report, isGranted, hookOn, routeOn, permissionRoutes }: { readonly name: string; readonly claude: Claude; readonly report: (id: string, message: string, error: unknown) => void; readonly isGranted: (permission: string) => boolean; readonly hookOn?: HookOn; readonly routeOn?: RouteOn; readonly permissionRoutes?: () => PermissionRoutes | undefined }): SessionsClient {
|
|
144
145
|
const call = <Answer>(method: string, input: unknown) => claude.cmod.call({ to: cmodPluginName, method, input }) as Promise<Answer>
|
|
@@ -156,7 +157,7 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
156
157
|
queued.add(key)
|
|
157
158
|
void Promise.resolve().then(async () => {
|
|
158
159
|
queued.delete(key)
|
|
159
|
-
const heard = (from: Map<string, Set<Listening>>):
|
|
160
|
+
const heard = (from: Map<string, Set<Listening>>): Listened[] =>
|
|
160
161
|
[...(from.get(key) ?? [])].map(({ event, matcher, options, scope }) => ({ event, matcher: matcher === undefined ? undefined : sentMatcher(matcher), ...(options.onFailure === 'refuse' ? { onFailure: 'refuse' as const, scope } : {}) }))
|
|
161
162
|
const tracksTasks = [...(listening.get(key) ?? []), ...(deciding.get(key) ?? [])].some(({ scope }) => scope.task !== undefined || scope.isGoal === true)
|
|
162
163
|
const subscriptions = [...heard(listening), ...(tracksTasks ? [{ event: TaskStart }] : [])]
|
|
@@ -182,7 +183,7 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
182
183
|
if (change.type === TaskStart) parents.get(session)?.set(change.task.id, change.task.parent)
|
|
183
184
|
}
|
|
184
185
|
|
|
185
|
-
const add = (into: Map<string, Set<Listening>>, scope: Scope, event: SessionEvent, method: string, args: readonly unknown[], onChange: (key: string) => void):
|
|
186
|
+
const add = (into: Map<string, Set<Listening>>, scope: Scope, event: SessionEvent, method: string, args: readonly unknown[], onChange: (key: string) => void): Subscription => {
|
|
186
187
|
const key = keyOf(scope)
|
|
187
188
|
const entry: Listening = { scope, event, method, ...listenerOf(name, method, args) }
|
|
188
189
|
const set = into.get(key) ?? new Set()
|
|
@@ -191,7 +192,7 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
191
192
|
if (scope.task !== undefined || scope.isGoal === true) void learnTasks(scope.session ?? current.id).catch((error: unknown) => report(`tasks:${key}`, 'Could not read the tasks it listens under', error))
|
|
192
193
|
onChange(key)
|
|
193
194
|
return {
|
|
194
|
-
|
|
195
|
+
remove() {
|
|
195
196
|
for (const [at, held] of into) {
|
|
196
197
|
if (!held.delete(entry)) continue
|
|
197
198
|
if (held.size === 0) into.delete(at)
|
|
@@ -224,7 +225,7 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
224
225
|
const payload = await payloadFor(session, latest)
|
|
225
226
|
const answer = await invoke(entry.method, entry.options, async (time) => takenAnswer(entry.method, beforeAnswerKeys[event], await entry.listener(payload as never, time)), beforeAnswerKeys[event])
|
|
226
227
|
if (answer === undefined || answer === null || (typeof answer === 'object' && 'refuse' in answer) || isGranted(beforePermission(event))) return answer
|
|
227
|
-
throw new Error(`Its ${entry.method} answer changes nothing until ${beforePermission(event)}
|
|
228
|
+
throw new Error(`Its ${entry.method} answer changes nothing until you grant ${name} "${permissionWords(beforePermission(event))}". ${grantFix}`)
|
|
228
229
|
})
|
|
229
230
|
const answer = await foldBefore(event, change, askers, (error) => report(`session:${methodOf('before', event)}`, `The ${methodOf('before', event)} listener failed`, error))
|
|
230
231
|
return answer?.note === undefined ? answer : { ...answer, note: modNote(name, answer.note) }
|
|
@@ -534,7 +535,7 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
534
535
|
return { ...compacted, messages: [...compacted.messages, { role: 'user', text: modNote(name, note), toolUses: [] }] }
|
|
535
536
|
}
|
|
536
537
|
|
|
537
|
-
const listen = (method: string, args: readonly unknown[], isEveryAgent = false):
|
|
538
|
+
const listen = (method: string, args: readonly unknown[], isEveryAgent = false): Subscription => {
|
|
538
539
|
const heard = listenerOf(name, method, args)
|
|
539
540
|
const entry = { ...heard, options: method === 'beforePermissionRequest' ? { ...heard.options, onFailure: 'refuse' } : heard.options, isEveryAgent } as ProcessListener
|
|
540
541
|
const held = processListeners.get(method) ?? new Set()
|
|
@@ -543,7 +544,7 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
543
544
|
processListeners.set(method, held)
|
|
544
545
|
}
|
|
545
546
|
held.add(entry)
|
|
546
|
-
return {
|
|
547
|
+
return { remove: () => void held.delete(entry) }
|
|
547
548
|
}
|
|
548
549
|
|
|
549
550
|
const processMethods = (): ProcessOnMethods & ProcessBeforeMethods =>
|
|
@@ -552,7 +553,14 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
552
553
|
) as ProcessOnMethods & ProcessBeforeMethods
|
|
553
554
|
|
|
554
555
|
const here = sessionOf(() => current)
|
|
555
|
-
const currentSession = Object.assign(here, processMethods(), {
|
|
556
|
+
const currentSession = Object.assign(here, processMethods(), {
|
|
557
|
+
goal: Object.assign(here.goal, { beforeSessionStart: (...args: unknown[]) => listen('beforeSessionStart', args, true) }),
|
|
558
|
+
async scrollTo(message: Message, options?: { readonly block?: 'start' | 'center' | 'end' | 'nearest' }) {
|
|
559
|
+
const { deny } = await claude.ui.scroll({ to: { requestId: message.id }, ...(options?.block === undefined ? {} : { block: options.block }) })
|
|
560
|
+
if (deny !== undefined) throw new Error(`${name}: Claude Code did not scroll to the message: ${deny}`)
|
|
561
|
+
},
|
|
562
|
+
caption: (toolCall: Message & { readonly type: typeof ToolCall }, text: string | undefined) => claude.ui.notice(toolCall.callId, text),
|
|
563
|
+
})
|
|
556
564
|
const every = eventMethods(() => ({}), false)
|
|
557
565
|
|
|
558
566
|
const answerBefore = async (session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean) => (mayDecide(session) ? combined(session, event, change, isInProject) : undefined)
|
|
@@ -596,6 +604,7 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
596
604
|
failedBefore,
|
|
597
605
|
raise,
|
|
598
606
|
listen,
|
|
607
|
+
turn: (data) => turnOf({ session: current.id }, data),
|
|
599
608
|
follow(next) {
|
|
600
609
|
const moved = next.id !== current.id && current.id !== ''
|
|
601
610
|
const previous = current.id
|
|
@@ -618,9 +627,9 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
|
|
|
618
627
|
export type ToolCheck = { readonly tool: string; readonly input: unknown; readonly tool_use_id?: string; readonly agentId?: string; readonly ceiling?: 'allow' | 'ask' | 'deny' }
|
|
619
628
|
|
|
620
629
|
export type RouteOn = {
|
|
621
|
-
(event: 'tool.check', hook: (e: ToolCheck, next: (e: ToolCheck) => Promise<unknown>) => Promise<unknown>, failed?: (e: ToolCheck, error: Error, next: (e: ToolCheck) => Promise<unknown>) => Promise<{ readonly decision: 'deny'; readonly reason: string } | undefined>):
|
|
622
|
-
(event: 'command.run', hook: (e: Args<'command.run'>, next: (e: Args<'command.run'>) => Promise<CommandRunResult>) => Promise<CommandRunResult>, failed?: (e: Args<'command.run'>, error: Error) => Promise<CommandRunResult | undefined>):
|
|
623
|
-
(event: 'session.compact', hook: (e: Args<'session.compact'>, next: (e: Args<'session.compact'>) => Promise<SessionCompactResult>) => Promise<SessionCompactResult>):
|
|
630
|
+
(event: 'tool.check', hook: (e: ToolCheck, next: (e: ToolCheck) => Promise<unknown>) => Promise<unknown>, failed?: (e: ToolCheck, error: Error, next: (e: ToolCheck) => Promise<unknown>) => Promise<{ readonly decision: 'deny'; readonly reason: string } | undefined>): Subscription
|
|
631
|
+
(event: 'command.run', hook: (e: Args<'command.run'>, next: (e: Args<'command.run'>) => Promise<CommandRunResult>) => Promise<CommandRunResult>, failed?: (e: Args<'command.run'>, error: Error) => Promise<CommandRunResult | undefined>): Subscription
|
|
632
|
+
(event: 'session.compact', hook: (e: Args<'session.compact'>, next: (e: Args<'session.compact'>) => Promise<SessionCompactResult>) => Promise<SessionCompactResult>): Subscription
|
|
624
633
|
}
|
|
625
634
|
|
|
626
635
|
export const recordOf = (value: unknown): Readonly<Record<string, unknown>> => (typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {})
|
package/src/runtime/state.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ClassicHookInputs } from 'claude-code'
|
|
2
|
+
import type { Subscription } from '../utils/subscription.js'
|
|
2
3
|
import type { Claude } from './claude.js'
|
|
3
4
|
import type { Failure } from './toaster.js'
|
|
4
5
|
|
|
@@ -34,10 +35,11 @@ const keptProjects = 20
|
|
|
34
35
|
|
|
35
36
|
const listeners = new WeakMap<object, Set<() => void>>()
|
|
36
37
|
|
|
37
|
-
export function onStateChange(state: object, listener: () => void):
|
|
38
|
+
export function onStateChange(state: object, listener: () => void): Subscription {
|
|
38
39
|
const own = listeners.get(state) ?? new Set()
|
|
39
40
|
own.add(listener)
|
|
40
41
|
listeners.set(state, own)
|
|
42
|
+
return { remove: () => void own.delete(listener) }
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
export async function sessionsWith(claude: Pick<Claude, 'store'>, name: string, key: string, value: unknown): Promise<string[]> {
|
package/src/runtime/toaster.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ElementTable, RenderElement, RenderPropsOf, Timer } from 'claude-code'
|
|
2
2
|
import type { Toast, ToastId, ToastOptions, ToastType } from '../mod.js'
|
|
3
3
|
import { Box, Button, drawWith, Link, Text } from '../ui/elements.js'
|
|
4
|
-
import { coreVersion, isObject } from '../records.js'
|
|
4
|
+
import { coreVersion, grantFix, installFix, isObject, secretFix } from '../records.js'
|
|
5
5
|
import { firstLine, messageOf } from '../utils/text.js'
|
|
6
6
|
import type { Claude } from './claude.js'
|
|
7
7
|
|
|
@@ -54,7 +54,7 @@ const icons: Record<Exclude<ToastType, 'loading'>, { readonly glyph: string; rea
|
|
|
54
54
|
error: { glyph: '✗', color: 'error' },
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
export function createToaster(claude: Claude, name: string, version: () => string | undefined): Toaster {
|
|
57
|
+
export function createToaster(claude: Claude, name: string, version: () => string | undefined, openPermissions: () => unknown): Toaster {
|
|
58
58
|
const entries = new Map<ToastId, Entry>()
|
|
59
59
|
const hidden = new Map<string, string>()
|
|
60
60
|
void claude.store.get(hiddenKey).then(
|
|
@@ -176,7 +176,7 @@ export function createToaster(claude: Claude, name: string, version: () => strin
|
|
|
176
176
|
.then(run)
|
|
177
177
|
.catch((error: unknown) => fail({ id: `press:${entry.id}`, message: `${entry.message}: the press failed`, error }))
|
|
178
178
|
|
|
179
|
-
const fail = ({ id, message, error, description = error === undefined ? undefined : describe(error), retry }: Failure): ToastId => {
|
|
179
|
+
const fail = ({ id, message, error, description = error === undefined || describe(error) === message ? undefined : describe(error), retry }: Failure): ToastId => {
|
|
180
180
|
const hides = `${version() ?? ''}\n${message}\n${error === undefined ? '' : describe(error)}`
|
|
181
181
|
if (hidden.get(id) === hides) return id
|
|
182
182
|
hidden.delete(id)
|
|
@@ -214,7 +214,13 @@ export function createToaster(claude: Claude, name: string, version: () => strin
|
|
|
214
214
|
}
|
|
215
215
|
}
|
|
216
216
|
}
|
|
217
|
-
const
|
|
217
|
+
const text = `${message}\n${description ?? ''}`
|
|
218
|
+
const fixes = [
|
|
219
|
+
{ sentence: grantFix, label: 'Settings', onClick: openPermissions },
|
|
220
|
+
{ sentence: installFix, label: 'Plugins', onClick: () => claude.command.run({ command: 'plugin' }) },
|
|
221
|
+
{ sentence: secretFix, label: 'Open /config', onClick: () => claude.command.run({ command: 'config' }) },
|
|
222
|
+
].filter(({ sentence }) => text.includes(sentence))
|
|
223
|
+
const actions = [...fixes, ...(retry === undefined ? [] : [{ label: 'Retry', onClick: tryAgain }]), { label: 'Report', onClick: startReporting }]
|
|
218
224
|
return put('error', message, { id, ...(description === undefined ? {} : { description }), duration: Infinity }, actions, hides)
|
|
219
225
|
}
|
|
220
226
|
|