@cmodjs/core 0.2.3 → 1.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 +58 -10
- package/docs/dependencies.md +12 -12
- package/docs/hooks.md +41 -28
- package/docs/index.md +29 -25
- package/docs/install-steps.md +9 -7
- package/docs/jobs.md +59 -111
- package/docs/mod.md +200 -174
- package/docs/options.md +2 -2
- package/docs/permissions.md +27 -16
- package/docs/session.md +248 -0
- package/docs/state.md +11 -7
- package/docs/testing.md +66 -23
- package/docs/ui.md +108 -76
- package/jobs/check.d.ts.map +1 -1
- package/jobs/check.js +5 -2
- package/jobs/context.d.ts +7 -8
- package/jobs/context.d.ts.map +1 -1
- package/jobs/context.js +23 -73
- package/jobs/permissions/decide-permission.d.ts +5 -3
- package/jobs/permissions/decide-permission.d.ts.map +1 -1
- package/jobs/permissions/decide-permission.js +6 -4
- package/jobs/permissions/find-project-scope.d.ts +1 -1
- package/jobs/permissions/find-project-scope.d.ts.map +1 -1
- package/jobs/permissions/find-project-scope.js +8 -11
- package/jobs/permissions/match-target.d.ts +2 -2
- package/jobs/permissions/match-target.d.ts.map +1 -1
- package/jobs/permissions.d.ts +1 -1
- package/jobs/permissions.d.ts.map +1 -1
- package/jobs/permissions.js +6 -4
- package/jobs/program.d.ts.map +1 -1
- package/jobs/program.js +8 -3
- package/jobs/prompt.d.ts +4 -2
- package/jobs/prompt.d.ts.map +1 -1
- package/jobs/prompt.js +16 -15
- package/jobs/schedule.d.ts +2 -2
- package/jobs/schedule.d.ts.map +1 -1
- package/jobs/schedule.js +9 -9
- package/jobs/slash-command.d.ts +4 -4
- package/jobs/slash-command.d.ts.map +1 -1
- package/jobs/slash-command.js +14 -9
- package/jobs/status-line.d.ts +1 -11
- package/jobs/status-line.d.ts.map +1 -1
- package/jobs/status-line.js +8 -12
- package/jobs/tool.d.ts +2 -2
- package/jobs/tool.d.ts.map +1 -1
- package/jobs/tool.js +15 -9
- package/mod.d.ts +154 -75
- package/mod.d.ts.map +1 -1
- package/mod.js +94 -3
- package/options.d.ts +2 -2
- package/options.js +19 -19
- package/package.json +3 -3
- package/records.d.ts +10 -8
- package/records.d.ts.map +1 -1
- package/records.js +28 -17
- package/register.d.ts.map +1 -1
- package/register.js +18 -6
- package/runtime/announce.d.ts +18 -0
- package/runtime/announce.d.ts.map +1 -0
- package/runtime/announce.js +66 -0
- package/runtime/bound.d.ts +4 -0
- package/runtime/bound.d.ts.map +1 -0
- package/runtime/bound.js +9 -0
- package/runtime/claude.d.ts +3 -3
- package/runtime/claude.d.ts.map +1 -1
- package/runtime/conversations.d.ts +16 -0
- package/runtime/conversations.d.ts.map +1 -0
- package/runtime/conversations.js +291 -0
- package/runtime/deadline.d.ts +0 -1
- package/runtime/deadline.d.ts.map +1 -1
- package/runtime/deadline.js +1 -1
- package/runtime/dependencies.d.ts +2 -0
- package/runtime/dependencies.d.ts.map +1 -1
- package/runtime/dependencies.js +9 -0
- package/runtime/engine.d.ts +17 -0
- package/runtime/engine.d.ts.map +1 -0
- package/runtime/engine.js +10 -0
- package/runtime/grants.d.ts +4 -0
- package/runtime/grants.d.ts.map +1 -1
- package/runtime/grants.js +63 -33
- package/runtime/hooks.d.ts +5 -4
- package/runtime/hooks.d.ts.map +1 -1
- package/runtime/hooks.js +10 -5
- package/runtime/installer.d.ts.map +1 -1
- package/runtime/installer.js +11 -9
- package/runtime/lifecycle.d.ts +3 -1
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +317 -186
- package/runtime/metadata.d.ts +3 -1
- package/runtime/metadata.d.ts.map +1 -1
- package/runtime/metadata.js +21 -11
- package/runtime/options.d.ts +3 -1
- package/runtime/options.d.ts.map +1 -1
- package/runtime/options.js +4 -4
- package/runtime/programs.d.ts +8 -0
- package/runtime/programs.d.ts.map +1 -1
- package/runtime/programs.js +39 -1
- package/runtime/router.d.ts +2 -1
- package/runtime/router.d.ts.map +1 -1
- package/runtime/router.js +11 -3
- package/runtime/sessions.d.ts +36 -0
- package/runtime/sessions.d.ts.map +1 -0
- package/runtime/sessions.js +235 -0
- package/runtime/state.d.ts +6 -2
- package/runtime/state.d.ts.map +1 -1
- package/runtime/state.js +46 -13
- package/runtime/toaster.d.ts +31 -0
- package/runtime/toaster.d.ts.map +1 -0
- package/runtime/toaster.js +176 -0
- package/runtime/ui.d.ts +6 -17
- package/runtime/ui.d.ts.map +1 -1
- package/runtime/ui.js +36 -112
- package/session/context/context.d.ts +10 -0
- package/session/context/context.d.ts.map +1 -0
- package/session/context/context.js +2 -0
- package/session/context/turn/message/document.d.ts +7 -0
- package/session/context/turn/message/document.d.ts.map +1 -0
- package/session/context/turn/message/document.js +1 -0
- package/session/context/turn/message/message.d.ts +54 -0
- package/session/context/turn/message/message.d.ts.map +1 -0
- package/session/context/turn/message/message.js +137 -0
- package/session/context/turn/message/note.d.ts +8 -0
- package/session/context/turn/message/note.d.ts.map +1 -0
- package/session/context/turn/message/note.js +1 -0
- package/session/context/turn/message/notice.d.ts +6 -0
- package/session/context/turn/message/notice.d.ts.map +1 -0
- package/session/context/turn/message/notice.js +1 -0
- package/session/context/turn/message/prompt.d.ts +8 -0
- package/session/context/turn/message/prompt.d.ts.map +1 -0
- package/session/context/turn/message/prompt.js +1 -0
- package/session/context/turn/message/reply.d.ts +6 -0
- package/session/context/turn/message/reply.d.ts.map +1 -0
- package/session/context/turn/message/reply.js +1 -0
- package/session/context/turn/message/sender.d.ts +14 -0
- package/session/context/turn/message/sender.d.ts.map +1 -0
- package/session/context/turn/message/sender.js +30 -0
- package/session/context/turn/message/skill.d.ts +7 -0
- package/session/context/turn/message/skill.d.ts.map +1 -0
- package/session/context/turn/message/skill.js +1 -0
- package/session/context/turn/message/tool-call.d.ts +9 -0
- package/session/context/turn/message/tool-call.d.ts.map +1 -0
- package/session/context/turn/message/tool-call.js +1 -0
- package/session/context/turn/message/tool-result.d.ts +8 -0
- package/session/context/turn/message/tool-result.d.ts.map +1 -0
- package/session/context/turn/message/tool-result.js +1 -0
- package/session/context/turn/turn.d.ts +12 -0
- package/session/context/turn/turn.d.ts.map +1 -0
- package/session/context/turn/turn.js +2 -0
- package/session/conversation.d.ts +64 -0
- package/session/conversation.d.ts.map +1 -0
- package/session/conversation.js +123 -0
- package/session/goal/goal.d.ts +10 -0
- package/session/goal/goal.d.ts.map +1 -0
- package/session/goal/goal.js +1 -0
- package/session/goal/task.d.ts +16 -0
- package/session/goal/task.d.ts.map +1 -0
- package/session/goal/task.js +3 -0
- package/session/session.d.ts +99 -0
- package/session/session.d.ts.map +1 -0
- package/session/session.js +1 -0
- package/src/jobs/check.ts +7 -4
- package/src/jobs/context.ts +24 -82
- package/src/jobs/permissions/decide-permission.ts +10 -8
- package/src/jobs/permissions/find-project-scope.ts +8 -14
- package/src/jobs/permissions/match-target.ts +3 -3
- package/src/jobs/permissions.ts +8 -6
- package/src/jobs/program.ts +7 -3
- package/src/jobs/prompt.ts +21 -24
- package/src/jobs/schedule.ts +14 -14
- package/src/jobs/slash-command.ts +20 -14
- package/src/jobs/status-line.ts +9 -21
- package/src/jobs/tool.ts +17 -11
- package/src/mod.ts +209 -97
- package/src/options.ts +22 -22
- package/src/records.ts +35 -23
- package/src/register.ts +18 -6
- package/src/runtime/announce.ts +74 -0
- package/src/runtime/bound.ts +12 -0
- package/src/runtime/claude.ts +5 -3
- package/src/runtime/conversations.ts +302 -0
- package/src/runtime/deadline.ts +2 -2
- package/src/runtime/dependencies.ts +12 -0
- package/src/runtime/engine.ts +22 -0
- package/src/runtime/grants.ts +66 -31
- package/src/runtime/hooks.ts +19 -10
- package/src/runtime/installer.ts +10 -9
- package/src/runtime/lifecycle.ts +313 -181
- package/src/runtime/metadata.ts +33 -11
- package/src/runtime/options.ts +19 -4
- package/src/runtime/programs.ts +40 -1
- package/src/runtime/router.ts +11 -4
- package/src/runtime/sessions.ts +242 -0
- package/src/runtime/state.ts +50 -14
- package/src/runtime/toaster.ts +218 -0
- package/src/runtime/ui.ts +42 -139
- package/src/session/context/context.ts +12 -0
- package/src/session/context/turn/message/document.ts +8 -0
- package/src/session/context/turn/message/message.ts +165 -0
- package/src/session/context/turn/message/note.ts +9 -0
- package/src/session/context/turn/message/notice.ts +7 -0
- package/src/session/context/turn/message/prompt.ts +9 -0
- package/src/session/context/turn/message/reply.ts +7 -0
- package/src/session/context/turn/message/sender.ts +38 -0
- package/src/session/context/turn/message/skill.ts +8 -0
- package/src/session/context/turn/message/tool-call.ts +10 -0
- package/src/session/context/turn/message/tool-result.ts +9 -0
- package/src/session/context/turn/turn.ts +14 -0
- package/src/session/conversation.ts +169 -0
- package/src/session/goal/goal.ts +9 -0
- package/src/session/goal/task.ts +19 -0
- package/src/session/session.ts +94 -0
- package/src/testing/fake-claude.ts +23 -18
- package/src/testing/fake-elements.ts +3 -2
- package/src/testing/fake-files.ts +1 -1
- package/src/testing/fake-sessions.ts +203 -0
- package/src/testing.ts +112 -33
- package/src/ui/components.ts +3 -3
- package/src/ui/elements.ts +22 -8
- package/src/ui/markdown.ts +22 -26
- package/src/ui/places.ts +49 -0
- package/src/utils/call-effects.ts +3 -3
- package/src/utils/changelog.ts +32 -0
- package/src/utils/matcher.ts +37 -0
- package/src/utils/metadata.ts +17 -15
- package/src/utils/parse-shell.ts +24 -16
- package/src/utils/paths.ts +15 -1
- package/src/utils/text.ts +4 -0
- package/testing/fake-claude.d.ts +4 -2
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +18 -12
- package/testing/fake-elements.js +4 -2
- package/testing/fake-files.js +1 -1
- package/testing/fake-sessions.d.ts +74 -0
- package/testing/fake-sessions.d.ts.map +1 -0
- package/testing/fake-sessions.js +170 -0
- package/testing.d.ts +33 -13
- package/testing.d.ts.map +1 -1
- package/testing.js +92 -21
- package/ui/components.d.ts +2 -3
- package/ui/components.d.ts.map +1 -1
- package/ui/components.js +3 -3
- package/ui/elements.d.ts.map +1 -1
- package/ui/elements.js +19 -9
- package/ui/markdown.d.ts +35 -33
- package/ui/markdown.d.ts.map +1 -1
- package/ui/markdown.js +14 -16
- package/ui/places.d.ts +43 -0
- package/ui/places.d.ts.map +1 -0
- package/ui/places.js +31 -0
- package/utils/call-effects.d.ts +1 -1
- package/utils/call-effects.d.ts.map +1 -1
- package/utils/call-effects.js +2 -2
- package/utils/changelog.d.ts +9 -0
- package/utils/changelog.d.ts.map +1 -0
- package/utils/changelog.js +29 -0
- package/utils/matcher.d.ts +13 -0
- package/utils/matcher.d.ts.map +1 -0
- package/utils/matcher.js +32 -0
- package/utils/metadata.d.ts +3 -3
- package/utils/metadata.js +18 -14
- package/utils/parse-shell.d.ts +1 -0
- package/utils/parse-shell.d.ts.map +1 -1
- package/utils/parse-shell.js +22 -13
- package/utils/paths.d.ts +2 -0
- package/utils/paths.d.ts.map +1 -1
- package/utils/paths.js +13 -1
- package/utils/text.d.ts +1 -0
- package/utils/text.d.ts.map +1 -1
- package/utils/text.js +3 -0
- package/src/ui/slots.ts +0 -36
- package/ui/slots.d.ts +0 -208
- package/ui/slots.d.ts.map +0 -1
- package/ui/slots.js +0 -19
package/src/runtime/metadata.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { FileMatch, Metadata } from '../mod.js'
|
|
2
2
|
import { formatOf, MetadataError, readMetadata, sidecarSuffix, writeMetadata, type MetadataFormat } from '../utils/metadata.js'
|
|
3
|
+
import { ifMissing, isMissing } from '../utils/paths.js'
|
|
3
4
|
import { messageOf } from '../utils/text.js'
|
|
4
5
|
import { picomatch, resolve } from '../vendor.js'
|
|
5
6
|
import type { Claude } from './claude.js'
|
|
7
|
+
import type { Failure } from './toaster.js'
|
|
6
8
|
|
|
7
9
|
export type Places = {
|
|
8
10
|
readonly home: string | undefined
|
|
@@ -34,7 +36,19 @@ export function resolvedPath(path: string, places: Places): string {
|
|
|
34
36
|
return path.startsWith('/') ? resolve(path) : resolve(places.projectRoot(), path)
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
export function createModFiles({
|
|
39
|
+
export function createModFiles({
|
|
40
|
+
name,
|
|
41
|
+
claude,
|
|
42
|
+
places,
|
|
43
|
+
checkWrite,
|
|
44
|
+
report,
|
|
45
|
+
}: {
|
|
46
|
+
readonly name: string
|
|
47
|
+
readonly claude: Claude
|
|
48
|
+
readonly places: Places
|
|
49
|
+
readonly checkWrite: (path: string) => Promise<void>
|
|
50
|
+
readonly report: (failure: Failure) => void
|
|
51
|
+
}): ModFiles {
|
|
38
52
|
const prefix = `${name}.`
|
|
39
53
|
const cache = new Map<string, { readonly signature: string; readonly read: Read }>()
|
|
40
54
|
const logged = new Set<string>()
|
|
@@ -48,12 +62,20 @@ export function createModFiles({ name, claude, places, checkWrite }: { readonly
|
|
|
48
62
|
const extensionless = !realPath.slice(realPath.lastIndexOf('/') + 1).includes('.')
|
|
49
63
|
const firstLine = extensionless ? (await claude.fs.read(realPath)).split('\n', 1)[0] : undefined
|
|
50
64
|
const format = formatOf(realPath, firstLine)
|
|
51
|
-
const holder = format.
|
|
52
|
-
const sidecar = format.
|
|
65
|
+
const holder = format.type === 'sidecar' ? `${realPath}${sidecarSuffix}` : realPath
|
|
66
|
+
const sidecar = format.type === 'sidecar' ? await claude.fs.stat(holder).catch(ifMissing(undefined)) : undefined
|
|
53
67
|
return { realPath, format, holder, signature: `${stat.mtimeMs}:${stat.size}:${sidecar?.kind === 'file' ? `${sidecar.mtimeMs}:${sidecar.size}` : ''}` }
|
|
54
68
|
}
|
|
55
69
|
|
|
56
|
-
const textOf = async (holder: string): Promise<string> => ((await claude.fs.stat(holder).catch((
|
|
70
|
+
const textOf = async (holder: string): Promise<string> => ((await claude.fs.stat(holder).catch(ifMissing(undefined)))?.kind === 'file' ? claude.fs.read(holder) : '')
|
|
71
|
+
|
|
72
|
+
const skipped = <Value>(path: string, value: Value) => (error: unknown): Value => {
|
|
73
|
+
if (isMissing(error)) return value
|
|
74
|
+
const reason = `${name}: fs.find skipped ${path}: ${messageOf(error)}`
|
|
75
|
+
if (!logged.has(reason)) claude.ui.log(reason, { to: 'debug' })
|
|
76
|
+
logged.add(reason)
|
|
77
|
+
return value
|
|
78
|
+
}
|
|
57
79
|
|
|
58
80
|
const readEntry = async (path: string): Promise<Read> => {
|
|
59
81
|
const located = await locate(path)
|
|
@@ -67,7 +89,7 @@ export function createModFiles({ name, claude, places, checkWrite }: { readonly
|
|
|
67
89
|
if (!(error instanceof MetadataError)) throw error
|
|
68
90
|
const failure = `${located.holder} ${error.message}`
|
|
69
91
|
read = { metadata: {}, error: failure }
|
|
70
|
-
if (!logged.has(failure))
|
|
92
|
+
if (!logged.has(failure)) report({ id: `metadata:${located.holder}`, message: `Skips the metadata of ${located.holder}`, description: failure })
|
|
71
93
|
logged.add(failure)
|
|
72
94
|
}
|
|
73
95
|
cache.set(path, { signature: located.signature, read })
|
|
@@ -77,21 +99,21 @@ export function createModFiles({ name, claude, places, checkWrite }: { readonly
|
|
|
77
99
|
const matchesOf = async (pattern: string): Promise<string[]> => {
|
|
78
100
|
const absolute = resolvedPath(pattern, places)
|
|
79
101
|
const scanned = picomatch.scan(absolute)
|
|
80
|
-
if (!scanned.isGlob) return (await claude.fs.stat(absolute).catch((
|
|
102
|
+
if (!scanned.isGlob) return (await claude.fs.stat(absolute).catch(skipped(absolute, undefined)))?.kind === 'file' ? [absolute] : []
|
|
81
103
|
const isMatch = picomatch(absolute, { dot: true })
|
|
82
104
|
const entersSkipped = skippedFolders.filter((folder) => scanned.glob.split('/').includes(folder))
|
|
83
105
|
const maxDepth = scanned.glob.includes('**') ? Number.POSITIVE_INFINITY : scanned.glob.split('/').length
|
|
84
106
|
const found: string[] = []
|
|
85
107
|
const visited = new Set<string>()
|
|
86
108
|
const walk = async (folder: string, depth: number): Promise<void> => {
|
|
87
|
-
const real = (await claude.fs.stat(folder, { resolve: true }).catch((
|
|
109
|
+
const real = (await claude.fs.stat(folder, { resolve: true }).catch(skipped(folder, undefined)))?.realPath ?? folder
|
|
88
110
|
if (visited.has(real)) return
|
|
89
111
|
visited.add(real)
|
|
90
|
-
const entries = await claude.fs.list(folder).catch((
|
|
112
|
+
const entries = await claude.fs.list(folder).catch(skipped(folder, []))
|
|
91
113
|
await Promise.all(
|
|
92
114
|
entries.map(async (entry) => {
|
|
93
115
|
const path = `${folder}/${entry.name}`
|
|
94
|
-
const kind = entry.isLink === true ? (await claude.fs.stat(path).catch((
|
|
116
|
+
const kind = entry.isLink === true ? (await claude.fs.stat(path).catch(skipped(path, undefined)))?.kind : entry.kind
|
|
95
117
|
if (kind === 'file' && isMatch(path)) found.push(path)
|
|
96
118
|
if (kind !== 'dir' || depth + 1 >= maxDepth) return
|
|
97
119
|
if (skippedFolders.includes(entry.name) && !entersSkipped.includes(entry.name)) return
|
|
@@ -100,7 +122,7 @@ export function createModFiles({ name, claude, places, checkWrite }: { readonly
|
|
|
100
122
|
)
|
|
101
123
|
}
|
|
102
124
|
const base = scanned.base === '' ? '/' : scanned.base
|
|
103
|
-
if ((await claude.fs.stat(base).catch((
|
|
125
|
+
if ((await claude.fs.stat(base).catch(skipped(base, undefined)))?.kind === 'dir') await walk(base, 0)
|
|
104
126
|
return found
|
|
105
127
|
}
|
|
106
128
|
|
|
@@ -111,7 +133,7 @@ export function createModFiles({ name, claude, places, checkWrite }: { readonly
|
|
|
111
133
|
const kept = paths.filter((path) => !(path.endsWith(sidecarSuffix) && paths.includes(path.slice(0, -sidecarSuffix.length))))
|
|
112
134
|
return Promise.all(
|
|
113
135
|
kept.map(async (path): Promise<FileMatch> => {
|
|
114
|
-
const read = await readEntry(path)
|
|
136
|
+
const read = await readEntry(path).catch((error: unknown): Read => ({ metadata: {}, error: `${path} ${messageOf(error)}` }))
|
|
115
137
|
return { path, ...(read.name === undefined ? {} : { name: read.name }), metadata: own(read.metadata), ...(read.error === undefined ? {} : { error: read.error }) }
|
|
116
138
|
}),
|
|
117
139
|
)
|
package/src/runtime/options.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { fitsOption, isUnset, type Option, type Options } from '../options.js'
|
|
|
3
3
|
import { configFolders } from '../records.js'
|
|
4
4
|
import { listed, messageOf } from '../utils/text.js'
|
|
5
5
|
import type { Claude } from './claude.js'
|
|
6
|
+
import type { Failure } from './toaster.js'
|
|
6
7
|
|
|
7
8
|
export class MissingOptions extends Error {
|
|
8
9
|
readonly keys: readonly string[]
|
|
@@ -21,7 +22,21 @@ export type ModOptions = {
|
|
|
21
22
|
load(root: string): Promise<void>
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
export function createOptions({
|
|
25
|
+
export function createOptions({
|
|
26
|
+
name,
|
|
27
|
+
declared,
|
|
28
|
+
fromClaude,
|
|
29
|
+
claude,
|
|
30
|
+
report,
|
|
31
|
+
changed,
|
|
32
|
+
}: {
|
|
33
|
+
readonly name: string
|
|
34
|
+
readonly declared: Options
|
|
35
|
+
readonly fromClaude: PluginOptions
|
|
36
|
+
readonly claude: Claude
|
|
37
|
+
readonly report: (failure: Failure) => void
|
|
38
|
+
readonly changed: () => void
|
|
39
|
+
}): ModOptions {
|
|
25
40
|
let values: Readonly<Record<string, unknown>> = merged(declared, fromClaude, {}, new Set())
|
|
26
41
|
let locked: Set<string> | undefined
|
|
27
42
|
let loadedRoot: string | undefined
|
|
@@ -41,7 +56,7 @@ export function createOptions({ name, declared, fromClaude, claude, changed }: {
|
|
|
41
56
|
if (project === undefined) return {}
|
|
42
57
|
const path = `${project.folder}/options.json`
|
|
43
58
|
if (!(await claude.fs.exists(path))) return {}
|
|
44
|
-
const ignore = (reason: string) =>
|
|
59
|
+
const ignore = (reason: string) => report({ id: `options:${path} ${reason}`, message: `Ignores part of ${path}`, description: `${path} ${reason}` })
|
|
45
60
|
let file: unknown
|
|
46
61
|
try {
|
|
47
62
|
file = JSON.parse(await claude.fs.read(path))
|
|
@@ -57,7 +72,7 @@ export function createOptions({ name, declared, fromClaude, claude, changed }: {
|
|
|
57
72
|
for (const [key, value] of Object.entries(file)) {
|
|
58
73
|
const option = declared[key]
|
|
59
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(', ')}.`}`)
|
|
60
|
-
else if (option.
|
|
75
|
+
else if (option.type === 'secret') ignore(`sets ${key}, a secret, and a project file is committed for everyone to read. Remove it, and set it in /config.`)
|
|
61
76
|
else {
|
|
62
77
|
const misfit = fitsOption(option, value)
|
|
63
78
|
if (misfit === undefined) kept[key] = value
|
|
@@ -98,5 +113,5 @@ function valueOf(key: string, option: Option, own: unknown, project: Readonly<Re
|
|
|
98
113
|
if (locked.has(key)) return own
|
|
99
114
|
if (Object.hasOwn(project, key)) return project[key]
|
|
100
115
|
if (!isUnset(own) && fitsOption(option, own) === undefined) return Array.isArray(own) ? Object.freeze([...own]) : own
|
|
101
|
-
return option.default ?? (option.
|
|
116
|
+
return option.default ?? (option.type === 'list' ? Object.freeze([]) : option.type === 'number' ? undefined : '')
|
|
102
117
|
}
|
package/src/runtime/programs.ts
CHANGED
|
@@ -1,8 +1,47 @@
|
|
|
1
|
+
import type { HookStream, ProcessSpawnChunk, ProcessSpawnResult } from 'claude-code'
|
|
1
2
|
import { storeFolder } from '../records.js'
|
|
3
|
+
import { ifMissing } from '../utils/paths.js'
|
|
4
|
+
import { formatExit } from '../utils/text.js'
|
|
2
5
|
import type { Claude } from './claude.js'
|
|
3
6
|
|
|
4
7
|
export type Programs = { readonly claude: Claude; refresh(): Promise<void> }
|
|
5
8
|
|
|
9
|
+
export function lastLineOf(text: string): string | undefined {
|
|
10
|
+
const trimmed = text.trim()
|
|
11
|
+
return trimmed === '' ? undefined : trimmed.slice(trimmed.lastIndexOf('\n') + 1).trim()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function cmodError(stderr: string, command: string | undefined): string {
|
|
15
|
+
const line = lastLineOf(stderr) ?? ''
|
|
16
|
+
const prefix = `cmod ${command}: `
|
|
17
|
+
return line.startsWith(prefix) ? line.slice(prefix.length) : line
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function readLines(stream: HookStream<ProcessSpawnChunk, ProcessSpawnResult>, onLine: (text: string) => void): Promise<{ code: number | null; lastError: string }> {
|
|
21
|
+
let pending = ''
|
|
22
|
+
let lastError = ''
|
|
23
|
+
let piece = await stream.next()
|
|
24
|
+
while (piece.done !== true) {
|
|
25
|
+
if (piece.value.stream === 'stderr') {
|
|
26
|
+
lastError = lastLineOf(piece.value.text) ?? lastError
|
|
27
|
+
} else {
|
|
28
|
+
const lines = `${pending}${piece.value.text}`.split('\n')
|
|
29
|
+
pending = lines.pop() ?? ''
|
|
30
|
+
for (const text of lines) onLine(text)
|
|
31
|
+
}
|
|
32
|
+
piece = await stream.next()
|
|
33
|
+
}
|
|
34
|
+
if (pending !== '') onLine(pending)
|
|
35
|
+
return { code: piece.value.code, lastError }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function runCmod(claude: Claude, args: readonly string[], session: string): Promise<string> {
|
|
39
|
+
const lines: string[] = []
|
|
40
|
+
const { code, lastError } = await readLines(claude.process.spawn({ argv: ['cmod', ...args], env: { CLAUDE_CODE_SESSION_ID: session } }), (line) => void lines.push(line))
|
|
41
|
+
if (code !== 0) throw new Error(cmodError(lastError, args[0]) || `cmod ${args.slice(0, 2).join(' ')} ${formatExit(code, '')}.`)
|
|
42
|
+
return lines.join('\n')
|
|
43
|
+
}
|
|
44
|
+
|
|
6
45
|
export function locatingPrograms(claude: Claude): Programs {
|
|
7
46
|
let folder: string | undefined
|
|
8
47
|
let names: ReadonlySet<string> = new Set()
|
|
@@ -21,7 +60,7 @@ export function locatingPrograms(claude: Claude): Programs {
|
|
|
21
60
|
async refresh() {
|
|
22
61
|
const [home, dataHome] = await Promise.all([claude.env.home(), claude.env.dataHome()])
|
|
23
62
|
const programs = `${storeFolder({ HOME: home, XDG_DATA_HOME: dataHome })}/programs`
|
|
24
|
-
names = new Set((await claude.fs.list(programs).catch((
|
|
63
|
+
names = new Set((await claude.fs.list(programs).catch(ifMissing([]))).map((entry) => entry.name))
|
|
25
64
|
folder = programs
|
|
26
65
|
},
|
|
27
66
|
}
|
package/src/runtime/router.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Args, EventResult, Frozen } from 'claude-code'
|
|
2
|
+
import type { Disposable } from '../mod.js'
|
|
2
3
|
import type { RoutedEvent } from './hooks.js'
|
|
3
4
|
|
|
4
5
|
export type RouterNext<N extends RoutedEvent> = (e: Frozen<Args<N>>) => Promise<EventResult<N>>
|
|
@@ -6,7 +7,7 @@ export type RouterNext<N extends RoutedEvent> = (e: Frozen<Args<N>>) => Promise<
|
|
|
6
7
|
export type RoutedHook<N extends RoutedEvent> = (e: Frozen<Args<N>>, next: RouterNext<N>) => EventResult<N> | Promise<EventResult<N>>
|
|
7
8
|
|
|
8
9
|
export type Router = {
|
|
9
|
-
add<N extends RoutedEvent>(event: N, hook: RoutedHook<N>):
|
|
10
|
+
add<N extends RoutedEvent>(event: N, hook: RoutedHook<N>): Disposable
|
|
10
11
|
has(event: RoutedEvent): boolean
|
|
11
12
|
dispatch<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>>
|
|
12
13
|
}
|
|
@@ -18,9 +19,15 @@ export function createRouter(): Router {
|
|
|
18
19
|
|
|
19
20
|
return {
|
|
20
21
|
add(event, hook) {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
const erased = hook as unknown as ErasedHook
|
|
23
|
+
hooks.set(event, [...(hooks.get(event) ?? []), erased])
|
|
24
|
+
return {
|
|
25
|
+
dispose() {
|
|
26
|
+
const chain = (hooks.get(event) ?? []).filter((each) => each !== erased)
|
|
27
|
+
if (chain.length === 0) hooks.delete(event)
|
|
28
|
+
else hooks.set(event, chain)
|
|
29
|
+
},
|
|
30
|
+
}
|
|
24
31
|
},
|
|
25
32
|
has: (event) => hooks.has(event),
|
|
26
33
|
dispatch<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>> {
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import type { Disposable } from '../mod.js'
|
|
2
|
+
import type { Context } from '../session/context/context.js'
|
|
3
|
+
import { Message } from '../session/context/turn/message/message.js'
|
|
4
|
+
import { Note } from '../session/context/turn/message/note.js'
|
|
5
|
+
import { Person } from '../session/context/turn/message/sender.js'
|
|
6
|
+
import type { Turn } from '../session/context/turn/turn.js'
|
|
7
|
+
import type { ContextData, GoalData, SessionChange, SessionData, TaskData, TurnData } from '../session/conversation.js'
|
|
8
|
+
import type { Goal } from '../session/goal/goal.js'
|
|
9
|
+
import type { Task } from '../session/goal/task.js'
|
|
10
|
+
import { Session, type SessionEvent, type SessionScope, type SessionSend, type Sessions, type TaskStartArgs, type Usage } from '../session/session.js'
|
|
11
|
+
import { matches, sentMatcher, type SentMatcher } from '../utils/matcher.js'
|
|
12
|
+
import type { Claude } from './claude.js'
|
|
13
|
+
import { cmodPluginName } from './dependencies.js'
|
|
14
|
+
|
|
15
|
+
export type Subscription = { readonly event: SessionEvent; readonly matcher: SentMatcher | undefined }
|
|
16
|
+
|
|
17
|
+
export const conversationMethods = {
|
|
18
|
+
sessions: 'cmod:sessions',
|
|
19
|
+
search: 'cmod:search',
|
|
20
|
+
messages: 'cmod:messages',
|
|
21
|
+
turns: 'cmod:turns',
|
|
22
|
+
contexts: 'cmod:contexts',
|
|
23
|
+
tasks: 'cmod:tasks',
|
|
24
|
+
goal: 'cmod:goal',
|
|
25
|
+
updateGoal: 'cmod:updateGoal',
|
|
26
|
+
subscribe: 'cmod:subscribe',
|
|
27
|
+
send: 'cmod:send',
|
|
28
|
+
changed: 'cmod:changed',
|
|
29
|
+
} as const
|
|
30
|
+
|
|
31
|
+
export type SessionsClient = {
|
|
32
|
+
readonly sessions: Sessions
|
|
33
|
+
changed(session: string, change: SessionChange): void
|
|
34
|
+
/** An empty title keeps the title the session already has. */
|
|
35
|
+
follow(current: Omit<SessionData, 'isRunning' | 'lastAt'>): void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type Listening = { readonly event: SessionEvent; readonly matcher: unknown; readonly listener: (payload: never) => unknown }
|
|
39
|
+
|
|
40
|
+
export async function usageOf(claude: Pick<Claude, 'session'>): Promise<Usage> {
|
|
41
|
+
const [model, usage] = await Promise.all([claude.session.model(), claude.session.usage()])
|
|
42
|
+
const { tokens, window, percent } = usage.context
|
|
43
|
+
return { model, context: { ...(tokens === undefined ? {} : { tokens }), window, ...(percent === undefined ? {} : { percent }) }, ...(usage.cost === undefined ? {} : { cost: { usd: usage.cost.usd } }) }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function payloadOf(change: SessionChange): unknown {
|
|
47
|
+
if (change.type === Message) return change.message
|
|
48
|
+
if ('turn' in change) return change.turn
|
|
49
|
+
if ('context' in change) return change.context
|
|
50
|
+
if ('task' in change) return change.task
|
|
51
|
+
return change.goal
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isHeardBy(event: SessionEvent, matcher: unknown, change: SessionChange): boolean {
|
|
55
|
+
const named = change.type === Message ? (event === Message || event === change.message.type) : event === change.type
|
|
56
|
+
return named && (matcher === undefined || matches(payloadOf(change), matcher))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createSessionsClient({ name, claude, report }: { readonly name: string; readonly claude: Claude; readonly report: (id: string, message: string, error: unknown) => void }): SessionsClient {
|
|
60
|
+
const call = <Answer>(method: string, input: unknown) => claude.cmod.call({ to: cmodPluginName, method, input }) as Promise<Answer>
|
|
61
|
+
const listening = new Map<string, Set<Listening>>()
|
|
62
|
+
const queued = new Set<string>()
|
|
63
|
+
let current: SessionData = { id: '', title: '', project: '', cwd: '', model: '', startedAt: '', lastAt: '', isRunning: true }
|
|
64
|
+
|
|
65
|
+
const subscribe = (session: string) => {
|
|
66
|
+
if (queued.has(session)) return
|
|
67
|
+
queued.add(session)
|
|
68
|
+
void Promise.resolve().then(async () => {
|
|
69
|
+
queued.delete(session)
|
|
70
|
+
const subscriptions: Subscription[] = [...(listening.get(session) ?? [])].map(({ event, matcher }) => ({ event, matcher: matcher === undefined ? undefined : sentMatcher(matcher) }))
|
|
71
|
+
await call(conversationMethods.subscribe, { session, subscriptions }).catch((error: unknown) => report(`sessions:${session}`, `Hears nothing from session ${session}`, error))
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const isCurrent = (id: string) => id === current.id
|
|
76
|
+
|
|
77
|
+
const refuseElsewhere = (id: string, what: string): never => {
|
|
78
|
+
throw new Error(`${name}: ${what} works on the session this mod runs in, and ${id} runs elsewhere or has ended.`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const turnOf = (session: string, data: TurnData): Turn => ({ ...data, messages: () => call<Message[]>(conversationMethods.messages, { session, turn: data.prompt.id }) })
|
|
82
|
+
|
|
83
|
+
const contextOf = (session: string, data: ContextData): Context => ({
|
|
84
|
+
...data,
|
|
85
|
+
turns: async () => (await call<TurnData[]>(conversationMethods.turns, { session, context: data.number })).map((turn) => turnOf(session, turn)),
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const taskOf = (session: string, data: TaskData): Task => ({
|
|
89
|
+
...data,
|
|
90
|
+
messages: () => call<Message[]>(conversationMethods.messages, { session, task: data.id }),
|
|
91
|
+
tasks: async () => (await call<TaskData[]>(conversationMethods.tasks, { session, task: data.id })).map((task) => taskOf(session, task)),
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const goalOf = (session: string, data: GoalData): Goal => ({
|
|
95
|
+
...data,
|
|
96
|
+
update: async ({ text }) => {
|
|
97
|
+
if (text.trim() === '') throw new Error(`${name}: a goal needs text.`)
|
|
98
|
+
await call(conversationMethods.updateGoal, { session, text })
|
|
99
|
+
},
|
|
100
|
+
tasks: async () => (await call<TaskData[]>(conversationMethods.tasks, { session })).map((task) => taskOf(session, task)),
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const payloadFor = (session: string, change: SessionChange): unknown => {
|
|
104
|
+
if (change.type === Message) return change.message
|
|
105
|
+
if ('turn' in change) return turnOf(session, change.turn)
|
|
106
|
+
if ('context' in change) return contextOf(session, change.context)
|
|
107
|
+
if ('task' in change) return taskOf(session, change.task)
|
|
108
|
+
return goalOf(session, change.goal)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const sessionOf = (read: () => SessionData) => {
|
|
112
|
+
const id = () => read().id
|
|
113
|
+
const session = {
|
|
114
|
+
get id() {
|
|
115
|
+
return read().id
|
|
116
|
+
},
|
|
117
|
+
get title() {
|
|
118
|
+
return read().title
|
|
119
|
+
},
|
|
120
|
+
get project() {
|
|
121
|
+
return read().project
|
|
122
|
+
},
|
|
123
|
+
get cwd() {
|
|
124
|
+
return read().cwd
|
|
125
|
+
},
|
|
126
|
+
get model() {
|
|
127
|
+
return read().model
|
|
128
|
+
},
|
|
129
|
+
get startedAt() {
|
|
130
|
+
return read().startedAt
|
|
131
|
+
},
|
|
132
|
+
get lastAt() {
|
|
133
|
+
return read().lastAt
|
|
134
|
+
},
|
|
135
|
+
get forkedFrom() {
|
|
136
|
+
return read().forkedFrom
|
|
137
|
+
},
|
|
138
|
+
get isRunning() {
|
|
139
|
+
return read().isRunning
|
|
140
|
+
},
|
|
141
|
+
async usage(): Promise<Usage> {
|
|
142
|
+
if (!isCurrent(id())) refuseElsewhere(id(), 'usage()')
|
|
143
|
+
return usageOf(claude)
|
|
144
|
+
},
|
|
145
|
+
on(event: SessionEvent, matcherOrListener: unknown, maybeListener?: (payload: never) => unknown): Disposable {
|
|
146
|
+
const listener = (maybeListener ?? matcherOrListener) as (payload: never) => unknown
|
|
147
|
+
const matcher = maybeListener === undefined ? undefined : matcherOrListener
|
|
148
|
+
const owner = id()
|
|
149
|
+
const heard: Listening = { event, matcher, listener }
|
|
150
|
+
const set = listening.get(owner) ?? new Set()
|
|
151
|
+
set.add(heard)
|
|
152
|
+
listening.set(owner, set)
|
|
153
|
+
subscribe(owner)
|
|
154
|
+
return {
|
|
155
|
+
dispose() {
|
|
156
|
+
if (!set.delete(heard)) return
|
|
157
|
+
if (set.size === 0) listening.delete(owner)
|
|
158
|
+
subscribe(owner)
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
async send({ type, text, as }: SessionSend) {
|
|
163
|
+
if (text.trim() === '') throw new Error(`${name}: send needs text.`)
|
|
164
|
+
if (isCurrent(id())) {
|
|
165
|
+
if (as === Session) throw new Error(`${name}: send with as: Session messages another session, and this one runs this mod.`)
|
|
166
|
+
if (type === Note) {
|
|
167
|
+
if (as !== undefined) throw new Error(`${name}: a Note is always from this mod, so it takes no as.`)
|
|
168
|
+
const added = await claude.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
|
|
169
|
+
if (added.deny !== undefined) throw new Error(`${name}: Claude Code refused the note: ${added.deny}`)
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
const submitted = await claude.prompt.submit({ text, ...(as === Person ? { asUser: true } : {}) })
|
|
173
|
+
if ('drop' in submitted && typeof submitted.drop === 'string') throw new Error(`${name}: Claude Code dropped the prompt: ${submitted.drop}`)
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
if (!read().isRunning) throw new Error(`${name}: session ${id()} has ended, so it takes no messages.`)
|
|
177
|
+
if (as === Session) {
|
|
178
|
+
if (type === Note) throw new Error(`${name}: a message from this session reaches Claude as a Prompt, so send it with type: Prompt.`)
|
|
179
|
+
const sent = await claude.session.send({ to: { sessionId: id() }, text })
|
|
180
|
+
if (sent.isDelivered !== true) throw new Error(`${name}: Claude Code could not deliver the message to session ${id()}.`)
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
await call(conversationMethods.send, { session: id(), type, text, ...(as === undefined ? {} : { as }) })
|
|
184
|
+
},
|
|
185
|
+
async startTask(task: TaskStartArgs): Promise<Task> {
|
|
186
|
+
if (!isCurrent(id())) refuseElsewhere(id(), 'startTask()')
|
|
187
|
+
const started = await claude.agent.spawn({
|
|
188
|
+
prompt: task.prompt,
|
|
189
|
+
...(task.name === undefined ? {} : { description: task.name, name: task.name }),
|
|
190
|
+
...(task.agentType === undefined ? {} : { subagentType: task.agentType }),
|
|
191
|
+
...(task.model === undefined ? {} : { model: task.model }),
|
|
192
|
+
...(task.cwd === undefined ? {} : { cwd: task.cwd }),
|
|
193
|
+
})
|
|
194
|
+
if (started.deny !== undefined) throw new Error(`${name}: Claude Code refused the task: ${started.deny}`)
|
|
195
|
+
const data: TaskData = { id: started.agentId ?? '', title: task.name ?? '', agentType: task.agentType ?? 'general-purpose', model: started.model, openedBy: '', openedAt: new Date().toISOString() }
|
|
196
|
+
return taskOf(id(), data)
|
|
197
|
+
},
|
|
198
|
+
messages: (options: { readonly after?: string; readonly last?: number } = {}) => call<Message[]>(conversationMethods.messages, { session: id(), ...options }),
|
|
199
|
+
turns: async (options: { readonly last?: number } = {}) => (await call<TurnData[]>(conversationMethods.turns, { session: id(), ...options })).map((turn) => turnOf(id(), turn)),
|
|
200
|
+
contexts: async () => (await call<ContextData[]>(conversationMethods.contexts, { session: id() })).map((context) => contextOf(id(), context)),
|
|
201
|
+
goal: async () => goalOf(id(), await call<GoalData>(conversationMethods.goal, { session: id() })),
|
|
202
|
+
}
|
|
203
|
+
return session
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const currentSession = sessionOf(() => current)
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
sessions: {
|
|
210
|
+
get current() {
|
|
211
|
+
return currentSession as unknown as Sessions['current']
|
|
212
|
+
},
|
|
213
|
+
list: async (scope: SessionScope = {}) =>
|
|
214
|
+
(await call<SessionData[]>(conversationMethods.sessions, scope)).map((data) => {
|
|
215
|
+
if (data.id !== current.id) return sessionOf(() => data) as unknown as Sessions['current']
|
|
216
|
+
if (current.title === '') current = { ...current, title: data.title }
|
|
217
|
+
return currentSession as unknown as Sessions['current']
|
|
218
|
+
}),
|
|
219
|
+
search: (text, options = {}) => call<Message[]>(conversationMethods.search, { text, ...options, ...(options.before === undefined ? {} : { before: { session: options.before.session, id: options.before.id } }) }),
|
|
220
|
+
},
|
|
221
|
+
changed(session, change) {
|
|
222
|
+
for (const { event, matcher, listener } of listening.get(session) ?? []) {
|
|
223
|
+
if (!isHeardBy(event, matcher, change)) continue
|
|
224
|
+
void Promise.resolve()
|
|
225
|
+
.then(() => listener(payloadFor(session, change) as never))
|
|
226
|
+
.catch((error: unknown) => report(`session:${event}`, `The ${event} listener failed`, error))
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
follow(next) {
|
|
230
|
+
const moved = next.id !== current.id && current.id !== ''
|
|
231
|
+
const previous = current.id
|
|
232
|
+
current = { ...next, title: next.title || (next.id === current.id ? current.title : ''), isRunning: true, lastAt: new Date().toISOString() }
|
|
233
|
+
if (!moved) return
|
|
234
|
+
const kept = listening.get(previous)
|
|
235
|
+
if (kept === undefined) return
|
|
236
|
+
listening.delete(previous)
|
|
237
|
+
listening.set(current.id, kept)
|
|
238
|
+
subscribe(previous)
|
|
239
|
+
subscribe(current.id)
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
}
|
package/src/runtime/state.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ClassicHookInputs } from 'claude-code'
|
|
2
|
-
import { messageOf } from '../utils/text.js'
|
|
3
2
|
import type { Claude } from './claude.js'
|
|
3
|
+
import type { Failure } from './toaster.js'
|
|
4
4
|
|
|
5
5
|
type Lifetime = 'memory' | 'session' | 'project' | 'global'
|
|
6
6
|
|
|
@@ -14,7 +14,9 @@ export type StateOptions = {
|
|
|
14
14
|
readonly session: string
|
|
15
15
|
readonly root: string
|
|
16
16
|
readonly claude: Claude
|
|
17
|
+
readonly report: (failure: Failure) => void
|
|
17
18
|
readonly changed: () => void
|
|
19
|
+
readonly forkParent: () => Promise<string | undefined>
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export type ModState<State extends object> = {
|
|
@@ -23,12 +25,12 @@ export type ModState<State extends object> = {
|
|
|
23
25
|
changed(): void
|
|
24
26
|
load(): Promise<void>
|
|
25
27
|
moveTo(root: string): Promise<void>
|
|
26
|
-
switchSession(session: string, source: ClassicHookInputs['SessionStart']['source']): Promise<void>
|
|
28
|
+
switchSession(session: string, source: ClassicHookInputs['SessionStart']['source'], transcript: string): Promise<void>
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
const lifetimes: readonly Lifetime[] = ['memory', 'session', 'project', 'global']
|
|
30
32
|
|
|
31
|
-
const
|
|
33
|
+
const keptProjects = 20
|
|
32
34
|
|
|
33
35
|
const listeners = new WeakMap<object, Set<() => void>>()
|
|
34
36
|
|
|
@@ -38,7 +40,16 @@ export function onStateChange(state: object, listener: () => void): void {
|
|
|
38
40
|
listeners.set(state, own)
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
export function
|
|
43
|
+
export async function sessionsWith(claude: Pick<Claude, 'store'>, name: string, key: string, value: unknown): Promise<string[]> {
|
|
44
|
+
const prefix = `${name}.${key}.sessions.`
|
|
45
|
+
const found: string[] = []
|
|
46
|
+
for (const stored of (await claude.store.keys()).filter((each) => each.startsWith(prefix))) {
|
|
47
|
+
if ((await claude.store.get(stored)) === value) found.push(stored.slice(prefix.length))
|
|
48
|
+
}
|
|
49
|
+
return found
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createState<State extends object>({ name, initial, session, root, claude, report, changed: redraw, forkParent }: StateOptions): ModState<State> {
|
|
42
53
|
const changed = () => {
|
|
43
54
|
redraw()
|
|
44
55
|
for (const listener of listeners.get(state) ?? []) listener()
|
|
@@ -51,7 +62,7 @@ export function createState<State extends object>({ name, initial, session, root
|
|
|
51
62
|
|
|
52
63
|
const inTurn = <Result>(task: () => Promise<Result>): Promise<Result> => {
|
|
53
64
|
const run = turn.then(task)
|
|
54
|
-
turn =
|
|
65
|
+
turn = Promise.allSettled([run])
|
|
55
66
|
return run
|
|
56
67
|
}
|
|
57
68
|
|
|
@@ -62,11 +73,25 @@ export function createState<State extends object>({ name, initial, session, root
|
|
|
62
73
|
const read = (lifetime: SavedLifetime, key: string, owner: string) => claude.store.get(storeKey(lifetime, key, owner))
|
|
63
74
|
|
|
64
75
|
const write = async (lifetime: SavedLifetime, key: string, value: unknown, owner: string) => {
|
|
65
|
-
if (lifetime
|
|
76
|
+
if (lifetime !== 'project') return claude.store.set(storeKey(lifetime, key, owner), value)
|
|
66
77
|
await claude.store.delete(storeKey(lifetime, key, owner))
|
|
67
78
|
await claude.store.set(storeKey(lifetime, key, owner), value)
|
|
68
79
|
const owners = (await claude.store.keys()).filter((stored) => stored.startsWith(ownersKey(lifetime, key)))
|
|
69
|
-
for (const oldest of owners.slice(0, -
|
|
80
|
+
for (const oldest of owners.slice(0, -keptProjects)) await claude.store.delete(oldest)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const transcriptsKey = `${name}.$transcripts.`
|
|
84
|
+
|
|
85
|
+
const forgetEndedSessions = async (current: string) => {
|
|
86
|
+
const stored = await claude.store.keys()
|
|
87
|
+
for (const recorded of stored.filter((key) => key.startsWith(transcriptsKey))) {
|
|
88
|
+
const owner = recorded.slice(transcriptsKey.length)
|
|
89
|
+
if (owner === current) continue
|
|
90
|
+
const transcript = await claude.store.get(recorded)
|
|
91
|
+
if (typeof transcript === 'string' && (await claude.fs.exists(transcript))) continue
|
|
92
|
+
for (const value of stored.filter((key) => key.startsWith(`${name}.`) && key.endsWith(`.sessions.${owner}`))) await claude.store.delete(value)
|
|
93
|
+
await claude.store.delete(recorded)
|
|
94
|
+
}
|
|
70
95
|
}
|
|
71
96
|
|
|
72
97
|
const loadGroups = async (only: readonly Lifetime[], next: { readonly root: string; readonly session: string }) => {
|
|
@@ -80,13 +105,15 @@ export function createState<State extends object>({ name, initial, session, root
|
|
|
80
105
|
changed()
|
|
81
106
|
}
|
|
82
107
|
|
|
83
|
-
const copySession = async (
|
|
108
|
+
const copySession = async (from: string, to: string) => {
|
|
84
109
|
for (const key of Object.keys(declared.session)) {
|
|
85
|
-
const saved = await read('session', key,
|
|
86
|
-
if (saved !== undefined) await write('session', key, saved,
|
|
110
|
+
const saved = await read('session', key, from)
|
|
111
|
+
if (saved !== undefined) await write('session', key, saved, to)
|
|
87
112
|
}
|
|
88
113
|
}
|
|
89
114
|
|
|
115
|
+
const hasSaved = async (owner: string) => (await claude.store.keys()).some((key) => key.startsWith(`${name}.`) && key.endsWith(`.sessions.${owner}`))
|
|
116
|
+
|
|
90
117
|
const group = (lifetime: Lifetime) =>
|
|
91
118
|
new Proxy(current[lifetime], {
|
|
92
119
|
set(target, key, value) {
|
|
@@ -97,7 +124,7 @@ export function createState<State extends object>({ name, initial, session, root
|
|
|
97
124
|
if (lifetime !== 'memory') {
|
|
98
125
|
const owner = lifetime === 'project' ? projectRoot : sessionId
|
|
99
126
|
inTurn(() => write(lifetime, key, value, owner)).catch((error: unknown) => {
|
|
100
|
-
|
|
127
|
+
report({ id: `state:${lifetime}.${key}`, message: `Could not keep state.${lifetime}.${key}. Keep only JSON data in mod.state`, error })
|
|
101
128
|
})
|
|
102
129
|
}
|
|
103
130
|
changed()
|
|
@@ -113,12 +140,21 @@ export function createState<State extends object>({ name, initial, session, root
|
|
|
113
140
|
return projectRoot
|
|
114
141
|
},
|
|
115
142
|
changed,
|
|
116
|
-
load: () =>
|
|
143
|
+
load: () =>
|
|
144
|
+
inTurn(async () => {
|
|
145
|
+
if (Object.keys(declared.session).length > 0 && !(await hasSaved(sessionId))) {
|
|
146
|
+
const parent = await forkParent()
|
|
147
|
+
if (parent !== undefined) await copySession(parent, sessionId)
|
|
148
|
+
}
|
|
149
|
+
return loadGroups(lifetimes, { root: projectRoot, session: sessionId })
|
|
150
|
+
}),
|
|
117
151
|
moveTo: (nextRoot) => inTurn(async () => (nextRoot === projectRoot ? undefined : loadGroups(['project'], { root: nextRoot, session: sessionId }))),
|
|
118
|
-
switchSession: (nextSession, source) =>
|
|
152
|
+
switchSession: (nextSession, source, transcript) =>
|
|
119
153
|
inTurn(async () => {
|
|
154
|
+
await claude.store.set(`${transcriptsKey}${nextSession}`, transcript)
|
|
155
|
+
await forgetEndedSessions(nextSession)
|
|
120
156
|
if (nextSession === sessionId) return
|
|
121
|
-
if (source === 'fork') await copySession(nextSession)
|
|
157
|
+
if (source === 'fork') await copySession(sessionId, nextSession)
|
|
122
158
|
return loadGroups(['memory', 'session'], { root: projectRoot, session: nextSession })
|
|
123
159
|
}),
|
|
124
160
|
}
|