@cmodjs/core 0.3.0 → 1.5.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 +61 -10
- package/docs/dependencies.md +16 -14
- package/docs/hooks.md +64 -29
- package/docs/index.md +29 -25
- package/docs/install-steps.md +13 -9
- package/docs/jobs.md +59 -111
- package/docs/mod.md +227 -175
- package/docs/options.md +2 -2
- package/docs/permissions.md +27 -16
- package/docs/session.md +311 -0
- package/docs/state.md +11 -7
- package/docs/testing.md +68 -23
- package/docs/ui.md +116 -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 +14 -4
- 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 +178 -75
- package/mod.d.ts.map +1 -1
- package/mod.js +127 -3
- package/options.d.ts +2 -2
- package/options.js +19 -19
- package/package.json +3 -3
- package/records.d.ts +13 -8
- package/records.d.ts.map +1 -1
- package/records.js +33 -17
- package/register.d.ts.map +1 -1
- package/register.js +34 -5
- package/runtime/announce.d.ts +18 -0
- package/runtime/announce.d.ts.map +1 -0
- package/runtime/announce.js +68 -0
- package/runtime/before.d.ts +32 -0
- package/runtime/before.d.ts.map +1 -0
- package/runtime/before.js +214 -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 +4 -2
- package/runtime/claude.d.ts.map +1 -1
- package/runtime/conversations.d.ts +17 -0
- package/runtime/conversations.d.ts.map +1 -0
- package/runtime/conversations.js +563 -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 +10 -1
- package/runtime/engine.d.ts +13 -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 +68 -31
- package/runtime/hooks.d.ts +5 -4
- package/runtime/hooks.d.ts.map +1 -1
- package/runtime/hooks.js +29 -9
- package/runtime/installer.d.ts +1 -1
- package/runtime/installer.d.ts.map +1 -1
- package/runtime/installer.js +17 -14
- package/runtime/lifecycle.d.ts +3 -1
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +414 -212
- package/runtime/metadata.d.ts +3 -1
- package/runtime/metadata.d.ts.map +1 -1
- package/runtime/metadata.js +25 -14
- 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 +58 -0
- package/runtime/sessions.d.ts.map +1 -0
- package/runtime/sessions.js +433 -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 +24 -0
- package/runtime/toaster.d.ts.map +1 -0
- package/runtime/toaster.js +286 -0
- package/runtime/ui.d.ts +6 -17
- package/runtime/ui.d.ts.map +1 -1
- package/runtime/ui.js +62 -118
- package/session/context/context.d.ts +12 -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 +55 -0
- package/session/context/turn/message/message.d.ts.map +1 -0
- package/session/context/turn/message/message.js +178 -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 +13 -0
- package/session/context/turn/turn.d.ts.map +1 -0
- package/session/context/turn/turn.js +2 -0
- package/session/conversation.d.ts +73 -0
- package/session/conversation.d.ts.map +1 -0
- package/session/conversation.js +146 -0
- package/session/goal/goal.d.ts +11 -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 +158 -0
- package/session/session.d.ts.map +1 -0
- package/session/session.js +2 -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 +13 -4
- 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 +252 -97
- package/src/options.ts +22 -22
- package/src/records.ts +40 -23
- package/src/register.ts +34 -5
- package/src/runtime/announce.ts +76 -0
- package/src/runtime/before.ts +200 -0
- package/src/runtime/bound.ts +12 -0
- package/src/runtime/claude.ts +7 -2
- package/src/runtime/conversations.ts +579 -0
- package/src/runtime/deadline.ts +2 -2
- package/src/runtime/dependencies.ts +13 -1
- package/src/runtime/engine.ts +23 -0
- package/src/runtime/grants.ts +69 -29
- package/src/runtime/hooks.ts +39 -14
- package/src/runtime/installer.ts +16 -14
- package/src/runtime/lifecycle.ts +413 -201
- package/src/runtime/metadata.ts +37 -14
- 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 +447 -0
- package/src/runtime/state.ts +50 -14
- package/src/runtime/toaster.ts +334 -0
- package/src/runtime/ui.ts +65 -143
- package/src/session/context/context.ts +14 -0
- package/src/session/context/turn/message/document.ts +8 -0
- package/src/session/context/turn/message/message.ts +203 -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 +15 -0
- package/src/session/conversation.ts +194 -0
- package/src/session/goal/goal.ts +10 -0
- package/src/session/goal/task.ts +19 -0
- package/src/session/session.ts +141 -0
- package/src/testing/fake-claude.ts +25 -17
- package/src/testing/fake-elements.ts +3 -2
- package/src/testing/fake-files.ts +1 -1
- package/src/testing/fake-sessions.ts +277 -0
- package/src/testing.ts +138 -35
- 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 +5 -5
- package/src/utils/changelog.ts +32 -0
- package/src/utils/disposable.ts +3 -0
- package/src/utils/matcher.ts +37 -0
- package/src/utils/metadata.ts +17 -15
- package/src/utils/parse-shell.ts +9 -8
- package/src/utils/paths.ts +21 -4
- package/src/utils/text.ts +4 -0
- package/testing/fake-claude.d.ts +5 -1
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +21 -12
- package/testing/fake-elements.js +4 -2
- package/testing/fake-files.js +1 -1
- package/testing/fake-sessions.d.ts +81 -0
- package/testing/fake-sessions.d.ts.map +1 -0
- package/testing/fake-sessions.js +249 -0
- package/testing.d.ts +34 -13
- package/testing.d.ts.map +1 -1
- package/testing.js +115 -23
- 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 +2 -1
- package/utils/call-effects.d.ts.map +1 -1
- package/utils/call-effects.js +3 -3
- package/utils/changelog.d.ts +9 -0
- package/utils/changelog.d.ts.map +1 -0
- package/utils/changelog.js +29 -0
- package/utils/disposable.d.ts +4 -0
- package/utils/disposable.d.ts.map +1 -0
- package/utils/disposable.js +1 -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.js +9 -7
- package/utils/paths.d.ts +4 -1
- package/utils/paths.d.ts.map +1 -1
- package/utils/paths.js +19 -4
- 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
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import type { Disposable } from '../mod.js'
|
|
2
|
+
import { ContextEnd, ContextStart, 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 { ToolResult } from '../session/context/turn/message/tool-result.js'
|
|
7
|
+
import { TurnEnd, TurnStart, type Turn } from '../session/context/turn/turn.js'
|
|
8
|
+
import type { ContextData, GoalData, SessionChange, SessionData, TaskData, TurnData } from '../session/conversation.js'
|
|
9
|
+
import { GoalChange, type Goal } from '../session/goal/goal.js'
|
|
10
|
+
import { TaskEnd, TaskStart, type Task } from '../session/goal/task.js'
|
|
11
|
+
import { Session, type BeforeEvent, type Client, type RecordPart, type SearchOptions, type SessionEvent, type SessionScope, type SessionSend, type Sessions, type TaskStartArgs, type ToolCallArgs, type Usage } from '../session/session.js'
|
|
12
|
+
import { matches, sentMatcher, type SentMatcher } from '../utils/matcher.js'
|
|
13
|
+
import { foldAnswers, type Answers, type Place } from './before.js'
|
|
14
|
+
import type { Claude } from './claude.js'
|
|
15
|
+
import { cmodPluginName } from './dependencies.js'
|
|
16
|
+
|
|
17
|
+
export type Subscription = { readonly event: SessionEvent; readonly matcher: SentMatcher | undefined }
|
|
18
|
+
|
|
19
|
+
export const everySession = '*'
|
|
20
|
+
|
|
21
|
+
export const conversationMethods = {
|
|
22
|
+
sessions: 'cmod:sessions',
|
|
23
|
+
search: 'cmod:search',
|
|
24
|
+
messages: 'cmod:messages',
|
|
25
|
+
turns: 'cmod:turns',
|
|
26
|
+
contexts: 'cmod:contexts',
|
|
27
|
+
tasks: 'cmod:tasks',
|
|
28
|
+
goal: 'cmod:goal',
|
|
29
|
+
updateGoal: 'cmod:updateGoal',
|
|
30
|
+
usage: 'cmod:usage',
|
|
31
|
+
subscribe: 'cmod:subscribe',
|
|
32
|
+
watchBefore: 'cmod:watchBefore',
|
|
33
|
+
act: 'cmod:act',
|
|
34
|
+
changed: 'cmod:changed',
|
|
35
|
+
before: 'cmod:before',
|
|
36
|
+
} as const
|
|
37
|
+
|
|
38
|
+
export type Act = 'send' | 'startTask' | 'callTool' | 'usage' | 'clients'
|
|
39
|
+
|
|
40
|
+
const actPermissions: Readonly<Record<Exclude<Act, 'callTool'>, string>> = { send: 'prompt', startTask: 'tasks', usage: 'conversation', clients: 'conversation' }
|
|
41
|
+
|
|
42
|
+
const startsTurns: readonly string[] = ['CronCreate']
|
|
43
|
+
|
|
44
|
+
const ownSchedule: readonly string[] = ['CronDelete', 'CronList']
|
|
45
|
+
|
|
46
|
+
export const beforePermission = (event: BeforeEvent): string => (event === 'toolCall' ? 'tools' : event === TaskStart || event === TaskEnd ? 'tasks' : 'prompt')
|
|
47
|
+
|
|
48
|
+
export const isAct =(action: string): action is Act => action === 'callTool' || action in actPermissions
|
|
49
|
+
|
|
50
|
+
export function toolPermission(tool: string): string | undefined {
|
|
51
|
+
return startsTurns.includes(tool) ? 'prompt' : ownSchedule.includes(tool) ? undefined : 'tools'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function actPermission(action: Act, input: unknown): string | undefined {
|
|
55
|
+
return action === 'callTool' ? (toolPermission(String((input as Partial<ToolCallArgs> | null)?.name)) ?? 'tools') : actPermissions[action]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type Scope = { readonly session?: string; readonly context?: number; readonly turn?: string; readonly task?: string; readonly isGoal?: true }
|
|
59
|
+
|
|
60
|
+
export type SessionsClient = {
|
|
61
|
+
readonly sessions: Sessions
|
|
62
|
+
changed(session: string, change: SessionChange, isInProject: boolean): void
|
|
63
|
+
answerBefore(session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean): Promise<unknown>
|
|
64
|
+
/** An empty title keeps the title the session already has. */
|
|
65
|
+
follow(current: Omit<SessionData, 'isRunning' | 'lastAt'>): void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type Listening = { scope: Scope; readonly event: SessionEvent; readonly matcher: unknown; readonly listener: (payload: never) => unknown }
|
|
69
|
+
|
|
70
|
+
type Owned = { readonly session: string; readonly task: string }
|
|
71
|
+
|
|
72
|
+
const asked = (action: string) => `cmod: ${action} runs only on a session's own main loop, and Claude Code gives a subagent's tasks no way to start a task or run a tool.`
|
|
73
|
+
|
|
74
|
+
export async function usageOf(claude: Pick<Claude, 'session'>): Promise<Usage> {
|
|
75
|
+
const [model, usage] = await Promise.all([claude.session.model(), claude.session.usage()])
|
|
76
|
+
const { tokens, window, percent } = usage.context
|
|
77
|
+
return { model, context: { ...(tokens === undefined ? {} : { tokens }), window, ...(percent === undefined ? {} : { percent }) }, ...(usage.cost === undefined ? {} : { cost: { usd: usage.cost.usd } }) }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function payloadOf(change: SessionChange): unknown {
|
|
81
|
+
if (change.type === Message) return change.message
|
|
82
|
+
if ('turn' in change) return change.turn
|
|
83
|
+
if ('context' in change) return change.context
|
|
84
|
+
if ('task' in change) {
|
|
85
|
+
const { id, title, agentType, model, openedBy, openedAt, closedAt } = change.task
|
|
86
|
+
return { id, title, openedBy, openedAt, ...(closedAt === undefined ? {} : { closedAt }), owner: { id, title, agentType, ...(model === undefined ? {} : { model }), startedAt: openedAt } }
|
|
87
|
+
}
|
|
88
|
+
if ('usage' in change) return change.usage
|
|
89
|
+
return change.goal
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isHeardBy(event: SessionEvent, matcher: unknown, change: SessionChange): boolean {
|
|
93
|
+
const named = change.type === Message ? event === Message || event === change.message.type : event === change.type
|
|
94
|
+
return named && (matcher === undefined || matches(payloadOf(change), matcher))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function taskOfChange(change: SessionChange): string | undefined {
|
|
98
|
+
if (change.type === Message) return change.message.task
|
|
99
|
+
if (change.type === TaskStart || change.type === TaskEnd) return change.task.id === '' ? change.task.parent : change.task.id
|
|
100
|
+
if (change.type === TurnStart || change.type === TurnEnd) return change.turn.prompt.task
|
|
101
|
+
if (change.type === ContextStart || change.type === ContextEnd) return change.context.task
|
|
102
|
+
if (change.type === GoalChange) return change.goal.task
|
|
103
|
+
return undefined
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function ownerOfChange(change: SessionChange): string | undefined {
|
|
107
|
+
if (change.type === TaskStart || change.type === TaskEnd) return change.task.parent
|
|
108
|
+
return taskOfChange(change)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function contextOfChange(change: SessionChange): number | undefined {
|
|
112
|
+
if (change.type === Message) return change.message.context
|
|
113
|
+
if (change.type === TurnStart || change.type === TurnEnd) return change.turn.context
|
|
114
|
+
if (change.type === ContextStart || change.type === ContextEnd) return change.context.number
|
|
115
|
+
if (change.type === TaskStart || change.type === TaskEnd) return change.task.context
|
|
116
|
+
return undefined
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function isInScope(scope: Scope, session: string, change: SessionChange, isUnder: (task: string, ancestor: string) => boolean): boolean {
|
|
120
|
+
if (scope.session !== undefined && scope.session !== session) return false
|
|
121
|
+
const task = taskOfChange(change)
|
|
122
|
+
if (scope.task !== undefined && (task === undefined || !isUnder(task, scope.task))) return false
|
|
123
|
+
if (scope.isGoal === true && change.type !== GoalChange && change.type !== TaskStart && (task === undefined || task === scope.task)) return false
|
|
124
|
+
if (scope.context !== undefined && (contextOfChange(change) !== scope.context || ownerOfChange(change) !== scope.task)) return false
|
|
125
|
+
if (scope.turn === undefined) return true
|
|
126
|
+
if (change.type === Message) return change.message.turn === scope.turn
|
|
127
|
+
return (change.type === TurnStart || change.type === TurnEnd) && change.turn.prompt.id === scope.turn
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function createSessionsClient({ name, claude, report, isGranted }: { readonly name: string; readonly claude: Claude; readonly report: (id: string, message: string, error: unknown) => void; readonly isGranted: (permission: string) => boolean }): SessionsClient {
|
|
131
|
+
const call = <Answer>(method: string, input: unknown) => claude.cmod.call({ to: cmodPluginName, method, input }) as Promise<Answer>
|
|
132
|
+
const listening = new Map<string, Set<Listening>>()
|
|
133
|
+
const deciding = new Map<string, Set<Listening>>()
|
|
134
|
+
const queued = new Set<string>()
|
|
135
|
+
const parents = new Map<string, Map<string, string | undefined>>()
|
|
136
|
+
const known = new Map<string, SessionData>()
|
|
137
|
+
let current: SessionData = { id: '', title: '', project: '', cwd: '', model: '', startedAt: '', lastAt: '', isRunning: true }
|
|
138
|
+
|
|
139
|
+
const keyOf = (scope: Scope) => scope.session ?? everySession
|
|
140
|
+
|
|
141
|
+
const subscribe = (key: string) => {
|
|
142
|
+
if (queued.has(key)) return
|
|
143
|
+
queued.add(key)
|
|
144
|
+
void Promise.resolve().then(async () => {
|
|
145
|
+
queued.delete(key)
|
|
146
|
+
const heard = (from: Map<string, Set<Listening>>): Subscription[] => [...(from.get(key) ?? [])].map(({ event, matcher }) => ({ event, matcher: matcher === undefined ? undefined : sentMatcher(matcher) }))
|
|
147
|
+
const tracksTasks = [...(listening.get(key) ?? []), ...(deciding.get(key) ?? [])].some(({ scope }) => scope.task !== undefined || scope.isGoal === true)
|
|
148
|
+
const subscriptions = [...heard(listening), ...(tracksTasks ? [{ event: TaskStart }] : [])]
|
|
149
|
+
await call(conversationMethods.subscribe, { session: key, subscriptions }).catch((error: unknown) => report(`sessions:${key}`, `Hears nothing from ${key === everySession ? 'the other sessions' : `session ${key}`}`, error))
|
|
150
|
+
await call(conversationMethods.watchBefore, { session: key, subscriptions: heard(deciding) }).catch((error: unknown) => report(`before:${key}`, `Changes nothing in ${key === everySession ? 'the other sessions' : `session ${key}`}`, error))
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const learnTasks = async (session: string) => {
|
|
155
|
+
if (parents.has(session)) return
|
|
156
|
+
const tree = new Map<string, string | undefined>()
|
|
157
|
+
parents.set(session, tree)
|
|
158
|
+
for (const task of await call<TaskData[]>(conversationMethods.tasks, { session, all: true })) tree.set(task.id, task.parent)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const isUnderIn = (session: string) => (task: string, ancestor: string) => {
|
|
162
|
+
const tree = parents.get(session)
|
|
163
|
+
for (let at: string | undefined = task, steps = 0; at !== undefined && steps < 64; at = tree?.get(at), steps += 1) if (at === ancestor) return true
|
|
164
|
+
return false
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const remember = (session: string, change: SessionChange) => {
|
|
168
|
+
if (change.type === TaskStart) parents.get(session)?.set(change.task.id, change.task.parent)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const add = (into: Map<string, Set<Listening>>, scope: Scope, event: SessionEvent, matcherOrListener: unknown, maybeListener: unknown, onChange: (key: string) => void): Disposable => {
|
|
172
|
+
const listener = (maybeListener ?? matcherOrListener) as (payload: never) => unknown
|
|
173
|
+
const matcher = maybeListener === undefined ? undefined : matcherOrListener
|
|
174
|
+
const key = keyOf(scope)
|
|
175
|
+
const entry: Listening = { scope, event, matcher, listener }
|
|
176
|
+
const set = into.get(key) ?? new Set()
|
|
177
|
+
set.add(entry)
|
|
178
|
+
into.set(key, set)
|
|
179
|
+
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))
|
|
180
|
+
onChange(key)
|
|
181
|
+
return {
|
|
182
|
+
dispose() {
|
|
183
|
+
for (const [at, held] of into) {
|
|
184
|
+
if (!held.delete(entry)) continue
|
|
185
|
+
if (held.size === 0) into.delete(at)
|
|
186
|
+
onChange(at)
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
const combined = async (session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean): Promise<Partial<Answers> | undefined> => {
|
|
196
|
+
const askers = [...(deciding.get(session) ?? []), ...(isInProject ? (deciding.get(everySession) ?? []) : [])]
|
|
197
|
+
.filter((entry) => entry.event === event)
|
|
198
|
+
.map((entry) => async (latest: SessionChange) => {
|
|
199
|
+
if (!isInScope(entry.scope, session, latest, isUnderIn(session))) return undefined
|
|
200
|
+
if (entry.matcher !== undefined && !matches(payloadOf(latest), entry.matcher)) return undefined
|
|
201
|
+
const answer = await entry.listener(payloadFor(session, latest) as never)
|
|
202
|
+
if (answer === undefined || isGranted(beforePermission(event))) return answer
|
|
203
|
+
throw new Error(`Its before(${event}) answer changes nothing until ${beforePermission(event)} is turned on for ${name} in /mods.`)
|
|
204
|
+
})
|
|
205
|
+
return foldAnswers(event, change, askers, (error) => report(`before:${event}`, `The before(${event}) listener failed`, error))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const recordPart = (scope: Scope): RecordPart => ({
|
|
209
|
+
search: (text: string, options: SearchOptions = {}) => call(conversationMethods.search, { text, ...scope, ...optionsOf(options) }),
|
|
210
|
+
on: (event: SessionEvent, matcherOrListener: unknown, maybeListener?: unknown) => add(listening, scope, event, matcherOrListener, maybeListener, subscribe),
|
|
211
|
+
before: (event: BeforeEvent, matcherOrListener: unknown, maybeListener?: unknown) => add(deciding, scope, event, matcherOrListener, maybeListener, subscribe),
|
|
212
|
+
}) as RecordPart
|
|
213
|
+
|
|
214
|
+
const turnOf = (scope: Scope, data: TurnData): Turn => {
|
|
215
|
+
const task = data.prompt.task ?? scope.task
|
|
216
|
+
return {
|
|
217
|
+
...data,
|
|
218
|
+
...recordPart({ ...scope, ...(task === undefined ? {} : { task }), turn: data.prompt.id }),
|
|
219
|
+
messages: () => call<Message[]>(conversationMethods.messages, { session: scope.session, turn: data.prompt.id, ...(task === undefined ? {} : { task }) }),
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const contextOf = (scope: Scope, data: ContextData): Context => {
|
|
224
|
+
const owned: Scope = { ...(scope.session === undefined ? {} : { session: scope.session }), ...(data.task === undefined ? {} : { task: data.task }) }
|
|
225
|
+
return {
|
|
226
|
+
...data,
|
|
227
|
+
...recordPart({ ...owned, context: data.number }),
|
|
228
|
+
turns: async () => (await call<TurnData[]>(conversationMethods.turns, { ...owned, context: data.number })).map((turn) => turnOf(owned, turn)),
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const taskOf = (session: string, data: TaskData): Task => ({
|
|
233
|
+
id: data.id,
|
|
234
|
+
title: data.title,
|
|
235
|
+
openedBy: data.openedBy,
|
|
236
|
+
openedAt: data.openedAt,
|
|
237
|
+
...(data.closedAt === undefined ? {} : { closedAt: data.closedAt }),
|
|
238
|
+
owner: ownerOf(session, data),
|
|
239
|
+
...recordPart({ session, task: data.id }),
|
|
240
|
+
tasks: async () => (await call<TaskData[]>(conversationMethods.tasks, { session, task: data.id })).map((task) => taskOf(session, task)),
|
|
241
|
+
messages: () => call<Message[]>(conversationMethods.messages, { session, task: data.id }),
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
const goalOf = (scope: Scope, data: GoalData): Goal => ({
|
|
245
|
+
...data,
|
|
246
|
+
...recordPart({ ...scope, isGoal: true }),
|
|
247
|
+
tasks: async () => (await call<TaskData[]>(conversationMethods.tasks, { session: scope.session, ...(scope.task === undefined ? {} : { task: scope.task }) })).map((task) => taskOf(scope.session ?? current.id, task)),
|
|
248
|
+
update: async ({ text }) => {
|
|
249
|
+
if (text.trim() === '') throw new Error(`${name}: a goal needs text.`)
|
|
250
|
+
await call(conversationMethods.updateGoal, { session: scope.session ?? current.id, ...(scope.task === undefined ? {} : { task: scope.task }), text })
|
|
251
|
+
},
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
const payloadFor = (session: string, change: SessionChange): unknown => {
|
|
255
|
+
const scope = { session }
|
|
256
|
+
if (change.type === Message) return change.message
|
|
257
|
+
if ('turn' in change) return turnOf(scope, change.turn)
|
|
258
|
+
if ('context' in change) return contextOf(scope, change.context)
|
|
259
|
+
if ('task' in change) return taskOf(session, change.task)
|
|
260
|
+
if ('usage' in change) return change.usage
|
|
261
|
+
return goalOf({ session, ...(change.goal.task === undefined ? {} : { task: change.goal.task }) }, { text: change.goal.text })
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const act = <Answer>(session: string, action: Act, input: unknown, owned?: Owned) => call<Answer>(conversationMethods.act, { session: owned?.session ?? session, action, input, ...(owned === undefined ? {} : { task: owned.task }) })
|
|
265
|
+
|
|
266
|
+
const sendHere = async ({ type, text, as }: SessionSend) => {
|
|
267
|
+
if (type === Note) {
|
|
268
|
+
if (as !== undefined) throw new Error(`${name}: a Note is always from this mod, so it takes no as.`)
|
|
269
|
+
const added = await claude.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
|
|
270
|
+
if (added.deny !== undefined) throw new Error(`${name}: Claude Code refused the note: ${added.deny}`)
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
const submitted = await claude.prompt.submit({ text, ...(as === Person ? { asUser: true } : {}) })
|
|
274
|
+
if ('drop' in submitted && typeof submitted.drop === 'string') throw new Error(`${name}: Claude Code dropped the prompt: ${submitted.drop}`)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const startHere = async (task: TaskStartArgs): Promise<Task> => {
|
|
278
|
+
const started = await claude.agent.spawn({
|
|
279
|
+
prompt: task.prompt,
|
|
280
|
+
...(task.name === undefined ? {} : { description: task.name, name: task.name }),
|
|
281
|
+
...(task.agentType === undefined ? {} : { subagentType: task.agentType }),
|
|
282
|
+
...(task.model === undefined ? {} : { model: task.model }),
|
|
283
|
+
...(task.cwd === undefined ? {} : { cwd: task.cwd }),
|
|
284
|
+
})
|
|
285
|
+
if (started.deny !== undefined) throw new Error(`${name}: Claude Code refused the task: ${started.deny}`)
|
|
286
|
+
return taskOf(current.id, { id: started.agentId ?? '', title: task.name ?? '', agentType: task.agentType ?? 'general-purpose', model: started.model, openedBy: '', openedAt: new Date().toISOString() })
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const clientsHere = async (): Promise<Client[]> => [...(await claude.session.surfaces())]
|
|
290
|
+
|
|
291
|
+
const sessionOf = (read: () => SessionData, owned?: Owned) => {
|
|
292
|
+
const id = () => read().id
|
|
293
|
+
const isHere = () => owned === undefined && id() === current.id
|
|
294
|
+
const scope = (): Scope => (owned === undefined ? { session: id() } : { session: owned.session, task: owned.task })
|
|
295
|
+
const where = () => (owned === undefined ? { session: id() } : { session: owned.session, task: owned.task })
|
|
296
|
+
const turnsOf = async (options: { readonly last?: number } = {}): Promise<Turn[]> => (await call<TurnData[]>(conversationMethods.turns, { ...where(), ...options })).map((turn) => turnOf(scope(), turn))
|
|
297
|
+
const contextsOf = async (): Promise<Context[]> => (await call<ContextData[]>(conversationMethods.contexts, where())).map((context) => contextOf(scope(), context))
|
|
298
|
+
const session = {
|
|
299
|
+
get id() {
|
|
300
|
+
return read().id
|
|
301
|
+
},
|
|
302
|
+
get title() {
|
|
303
|
+
return read().title
|
|
304
|
+
},
|
|
305
|
+
get project() {
|
|
306
|
+
return read().project
|
|
307
|
+
},
|
|
308
|
+
get agentType() {
|
|
309
|
+
return read().agentType
|
|
310
|
+
},
|
|
311
|
+
get model() {
|
|
312
|
+
return read().model
|
|
313
|
+
},
|
|
314
|
+
get cwd() {
|
|
315
|
+
return read().cwd
|
|
316
|
+
},
|
|
317
|
+
get isRunning() {
|
|
318
|
+
return read().isRunning
|
|
319
|
+
},
|
|
320
|
+
get startedAt() {
|
|
321
|
+
return read().startedAt
|
|
322
|
+
},
|
|
323
|
+
get lastAt() {
|
|
324
|
+
return read().lastAt
|
|
325
|
+
},
|
|
326
|
+
get forkedFrom() {
|
|
327
|
+
return read().forkedFrom
|
|
328
|
+
},
|
|
329
|
+
search: (text: string, options: SearchOptions = {}) => recordPart(scope()).search(text, options),
|
|
330
|
+
on: (event: SessionEvent, matcherOrListener: unknown, maybeListener?: unknown) => (recordPart(scope()).on as (...args: unknown[]) => Disposable)(event, matcherOrListener, maybeListener),
|
|
331
|
+
before: (event: BeforeEvent, matcherOrListener: unknown, maybeListener?: unknown) => (recordPart(scope()).before as (...args: unknown[]) => Disposable)(event, matcherOrListener, maybeListener),
|
|
332
|
+
goal: async () => goalOf(scope(), await call<GoalData>(conversationMethods.goal, where())),
|
|
333
|
+
context: async () => {
|
|
334
|
+
const all = await contextsOf()
|
|
335
|
+
const last = all.at(-1)
|
|
336
|
+
if (last === undefined) throw new Error(`${name}: session ${id()} has no context yet.`)
|
|
337
|
+
return last
|
|
338
|
+
},
|
|
339
|
+
contexts: contextsOf,
|
|
340
|
+
turn: async () => {
|
|
341
|
+
const [last] = await turnsOf({ last: 1 })
|
|
342
|
+
return last?.closedAt === undefined ? last : undefined
|
|
343
|
+
},
|
|
344
|
+
turns: turnsOf,
|
|
345
|
+
messages: (options: { readonly after?: string; readonly last?: number } = {}) => call<Message[]>(conversationMethods.messages, { ...where(), ...options }),
|
|
346
|
+
usage: async (): Promise<Usage> => (isHere() ? usageOf(claude) : act<Usage>(id(), 'usage', null, owned)),
|
|
347
|
+
clients: async (): Promise<Client[]> => (isHere() ? clientsHere() : act<Client[]>(id(), 'clients', null, owned)),
|
|
348
|
+
async send(message: SessionSend) {
|
|
349
|
+
if (message.text.trim() === '') throw new Error(`${name}: send needs text.`)
|
|
350
|
+
if (isHere() && message.as !== Session) return sendHere(message)
|
|
351
|
+
await act(id(), 'send', message, owned)
|
|
352
|
+
},
|
|
353
|
+
async startTask(task: TaskStartArgs): Promise<Task> {
|
|
354
|
+
if (owned !== undefined) throw new Error(asked('startTask'))
|
|
355
|
+
if (isHere()) return startHere(task)
|
|
356
|
+
const started = await act<TaskData>(id(), 'startTask', task)
|
|
357
|
+
return taskOf(id(), started)
|
|
358
|
+
},
|
|
359
|
+
async callTool(toolCall: ToolCallArgs): Promise<ToolResult> {
|
|
360
|
+
if (owned !== undefined) throw new Error(asked('callTool'))
|
|
361
|
+
return act<ToolResult>(id(), 'callTool', toolCall)
|
|
362
|
+
},
|
|
363
|
+
}
|
|
364
|
+
return session as unknown as Sessions['current']
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const ownerOf = (session: string, data: TaskData): Sessions['current'] => {
|
|
368
|
+
const parent = session === current.id ? current : known.get(session)
|
|
369
|
+
return sessionOf(
|
|
370
|
+
() => ({
|
|
371
|
+
id: data.id,
|
|
372
|
+
title: data.title,
|
|
373
|
+
project: parent?.project ?? '',
|
|
374
|
+
agentType: data.agentType,
|
|
375
|
+
model: data.model ?? '',
|
|
376
|
+
cwd: parent?.cwd ?? '',
|
|
377
|
+
isRunning: data.closedAt === undefined && (parent?.isRunning ?? true),
|
|
378
|
+
startedAt: data.openedAt,
|
|
379
|
+
lastAt: data.closedAt ?? data.openedAt,
|
|
380
|
+
}),
|
|
381
|
+
{ session, task: data.id },
|
|
382
|
+
)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const currentSession = sessionOf(() => current)
|
|
386
|
+
const every = recordPart({})
|
|
387
|
+
|
|
388
|
+
const answerBefore = async (session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean) => (isGranted('conversation') ? combined(session, event, change, isInProject) : undefined)
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
sessions: {
|
|
392
|
+
get current() {
|
|
393
|
+
return currentSession
|
|
394
|
+
},
|
|
395
|
+
list: async (scope: SessionScope = {}) =>
|
|
396
|
+
(await call<SessionData[]>(conversationMethods.sessions, scope)).map((data) => {
|
|
397
|
+
if (data.id !== current.id) {
|
|
398
|
+
known.set(data.id, data)
|
|
399
|
+
return sessionOf(() => known.get(data.id) ?? data)
|
|
400
|
+
}
|
|
401
|
+
if (current.title === '') current = { ...current, title: data.title }
|
|
402
|
+
return currentSession
|
|
403
|
+
}),
|
|
404
|
+
search: (text, options = {}) => call<Message[]>(conversationMethods.search, { text, ...optionsOf(options), ...(options.running === undefined ? {} : { running: options.running }), ...(options.since === undefined ? {} : { since: options.since }), ...(options.everyProject === undefined ? {} : { everyProject: options.everyProject }) }),
|
|
405
|
+
on: every.on,
|
|
406
|
+
before: every.before,
|
|
407
|
+
},
|
|
408
|
+
changed(session, change, isInProject) {
|
|
409
|
+
remember(session, change)
|
|
410
|
+
for (const key of isInProject ? [session, everySession] : [session]) {
|
|
411
|
+
for (const { scope, event, matcher, listener } of listening.get(key) ?? []) {
|
|
412
|
+
if (!isHeardBy(event, matcher, change) || !isInScope(scope, session, change, isUnderIn(session))) continue
|
|
413
|
+
void Promise.resolve()
|
|
414
|
+
.then(() => listener(payloadFor(session, change) as never))
|
|
415
|
+
.catch((error: unknown) => report(`session:${event}`, `The ${event} listener failed`, error))
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
answerBefore,
|
|
420
|
+
follow(next) {
|
|
421
|
+
const moved = next.id !== current.id && current.id !== ''
|
|
422
|
+
const previous = current.id
|
|
423
|
+
current = { ...next, title: next.title || (next.id === current.id ? current.title : ''), isRunning: true, lastAt: new Date().toISOString() }
|
|
424
|
+
if (!moved) return
|
|
425
|
+
for (const into of [listening, deciding]) {
|
|
426
|
+
const kept = into.get(previous)
|
|
427
|
+
if (kept === undefined) continue
|
|
428
|
+
into.delete(previous)
|
|
429
|
+
for (const entry of kept) if (entry.scope.session === previous) entry.scope = { ...entry.scope, session: current.id }
|
|
430
|
+
into.set(current.id, new Set([...(into.get(current.id) ?? []), ...kept]))
|
|
431
|
+
}
|
|
432
|
+
parents.delete(previous)
|
|
433
|
+
subscribe(previous)
|
|
434
|
+
subscribe(current.id)
|
|
435
|
+
},
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export function resultOf(where: Pick<Place, 'session' | 'context' | 'turn'>, answered: Awaited<ReturnType<Claude['tool']['call']>>): ToolResult {
|
|
440
|
+
const base: Omit<ToolResult, 'text' | 'isError'> = { id: '', type: ToolResult, at: new Date().toISOString(), session: where.session, context: where.context, ...(where.turn === undefined ? {} : { turn: where.turn.prompt.id }), callId: '' }
|
|
441
|
+
if (answered.deny !== undefined) return { ...base, text: answered.deny, isError: true }
|
|
442
|
+
return { ...base, text: answered.text ?? JSON.stringify(answered.result), isError: answered.isError === true }
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function optionsOf(options: SearchOptions): Readonly<Record<string, unknown>> {
|
|
446
|
+
return { ...(options.limit === undefined ? {} : { limit: options.limit }), ...(options.before === undefined ? {} : { before: { session: options.before.session, id: options.before.id } }) }
|
|
447
|
+
}
|
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
|
}
|