A progress band above the prompt and the Agents pane: each subagent gets a crab and a card with its status, progress, cost and time, a timeline and a…

Shows a progress band above the prompt and one live Agents pane where every subagent is a crab with its own card: watch it work, message it, stop it, or start a new one.
It was built for savvy-flow and works with any subagent.
/plugin marketplace add partalemre/MODZ
/plugin install savvy-progress@modz
Or in one step: /plugin install savvy-progress --marketplace partalemre/MODZ.
The MODZ panel (modz) shows its line here, for example Tasks 3/4 · 2 running.
The progress band above the prompt: the job title, a phase and count pill (Tasks 2/4, Review 3/4, Agents 1/3, Done), a thin segmented meter with one segment per task, the percentage, and a ×N button that opens the pane. It sits above other mods' rows and steps aside while a survey is open.
Numbers come only from what was observed: first the orchestrator's progress report, else the main loop's task list (TodoWrite, TaskCreate, TaskUpdate), else the agents started for this request (finished over total). A running agent that reports its own steps adds partial credit, and the percentage is then marked ~. With nothing to measure it shows —, never 0%. There is no estimated finish time.
The Agents pane (/agents-info):
2 running · 1 done · 1 planned) and Cost, Tokens, Time and the share of the 5-hour limit.Hide/Show): one strip per agent, a segment per tool call in the colour of its kind (read, edit, run, agent), and a "now" line.Nori · light · Opus 5.5 · low), the task, a status pill, a progress bar with its percentage, the current work (> Write src/Hero.tsx; the first line of its reply when done) and 3 tools · 0:56 · ≈$0.56. Progress is measured for an agent that reports its steps or task list, and estimated from its activity otherwise (~45%, a dashed bar that stays under 90% until it has replied).Details): the task given, a Now card (the open tool call and its full target), the agent's own steps, its last 8 calls, the files it touched (edited first), chips for time, context, tokens, cost and 5-hour share, and its result. A running agent has a message box (Message Nori, Enter sends) and a Stop Nori button, which goes through the normal permission chain..claude/agents/ and ~/.claude/agents/.Lv 1 to Lv 5, at 10, 50, 200 and 1000 answered turns) counts every answered main-loop turn on this machine. The terminal shows a face instead: (-_-) zz, (•_•), (^_^), (°_°;).shop · Fix the checkout total · waiting 3 min, up to three, then +2 more. Sessions that only work or sit idle are folded under one Other chats · 2 row at the foot, and only when there are any. The MODZ panel's line adds · 1 chat waits while one does.Each session writes one small heartbeat to this plugin's own store (a JSON file under your Claude Code configuration folder) under its own key, sessions/<session id>: the project (the repository's or the folder's name), a title (the first prompt's first line, 40 characters at most, with anything that looks like a key or token masked), what it is doing (working, waiting or idle) and since when. It writes when that changes and once every 30 seconds, reads the other sessions' keys at the same beat, and deletes its key when the session ends. A heartbeat older than 2 minutes belongs to a session that is gone, and whoever reads it deletes it. Nothing leaves the machine.
The pane lists the engine's own agent list (refreshed every second), so an agent that started before the mod loaded still shows correctly. The progress and step tool calls are not drawn in the transcript; a failed or interrupted call keeps its row.
/agents-info opens or closes the Agents pane. The Agents row in the MODZ panel only opens it.mcp__savvy-progress__progress: the orchestrator reports the plan, the phase and the accepted tasks.mcp__savvy-progress__step: a worker reports its own steps (done, total, note).| Option | Default | Meaning |
|---|---|---|
language | auto | tr, en or ru. auto follows MODZ_LANG, then Claude Code's language setting, then the system locale, else English. |
crabInBand | false | Shows the home crab at the right of the progress row while the session is idle. It never adds a row of its own: with no progress row there is no crab. |
{ "pluginConfigs": { "savvy-progress": { "options": { "language": "en" } } } }
~.waiting until it ends (the engine raises no event for the answer itself). A turn's end always clears it.The mod itself makes no model calls; the other chats and the home crab cost no tokens either. The two tools are called by Claude, which adds the cost of those tool calls, and a task you start from New task or a message you send runs a real agent at normal cost.
The interface is English. Set MODZ_LANG=tr in the env block of ~/.claude/settings.json for Turkish. Unlike the other mods, this one also follows Claude Code's language setting and your system locale when MODZ_LANG is not set, and it has Russian too.
Adapted from johnnyvizz/claude-kit (plugins/savvy-progress), licensed MIT. The license file is in this folder.
hooks/register.tsx 234 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentType } from '../types'
5import { BUILTIN_TYPES, mergeTypes, parseAgentFile } from './agent-types'
6import { isRunning } from './agents'
7import { onBand } from './band'
8import { onDispatch } from './dispatch'
9import { onEvents } from './events'
10import { PHASES } from './flow'
11import { hubCardOf } from './hub'
12import { isLang, langOf, setLang, t } from './i18n'
13import type { Lang } from './i18n'
14import { readingOf, trackLimit } from './limit'
15import { onPane } from './pane'
16import { onPresence } from './presence'
17import { reconcile } from './roster'
18import { isSame } from './same'
19import { HUB_PANE } from './state'
20import { onTranscript } from './transcript'
21
22// State refs live in each file that reads them: the scan follows literals, not imports.
23const agents = atom({ plugin: 'savvy-progress', key: 'agents' } as const, [])
24const now = atom({ plugin: 'savvy-progress', key: 'now' } as const, 0)
25const limit = atom({ plugin: 'savvy-progress', key: 'limit' } as const, null)
26const flow = atom({ plugin: 'savvy-progress', key: 'flow' } as const, null)
27const job = atom({ plugin: 'savvy-progress', key: 'job' } as const, null)
28const tasks = atom({ plugin: 'savvy-progress', key: 'tasks' } as const, [])
29const types = atom({ plugin: 'savvy-progress', key: 'types' } as const, BUILTIN_TYPES)
30const hub = atom({ plugin: 'savvy-progress', key: 'hub' } as const, { pane: HUB_PANE, detail: '', tone: 'idle', at: 0 })
31const chats = atom({ plugin: 'savvy-progress', key: 'chats' } as const, [])
32
33type On = Parameters<Register>[0]
34
35const TICK_MS = 1000
36// The project's agents folder (from the working directory), and the same folder under $HOME.
37const AGENTS_DIR = '.claude/agents'
38
39const PROGRESS_SPEC = {
40 name: 'progress',
41 description:
42 'Report /savvy-flow progress to the progress bar above the prompt and the agents panel. ' +
43 'Call it after presenting the plan (title, total, tasks, phase "delegate"), each time a task is accepted (done), ' +
44 'when switching phase or re-planning (tasks), and once at the end with finished: true. Fields left out keep their previous value.',
45 inputSchema: {
46 type: 'object',
47 properties: {
48 title: { type: 'string', description: 'Short name of the overall task, a few words.' },
49 total: { type: 'integer', minimum: 0, description: 'Number of planned worker tasks.' },
50 done: { type: 'integer', minimum: 0, description: 'Number of tasks accepted after review.' },
51 phase: { type: 'string', enum: [...PHASES] },
52 finished: { type: 'boolean', description: 'True once the flow is closed.' },
53 tasks: {
54 type: 'array',
55 description:
56 'The planned worker tasks in order, numbered from 1. Each title must equal the Agent tool `description` the task will be delegated with, so the panel can match runs to tasks.',
57 items: {
58 type: 'object',
59 properties: {
60 title: { type: 'string', description: 'A few words; reused verbatim as the Agent description.' },
61 tier: { type: 'string', enum: ['fable', 'heavy', 'careful', 'medium', 'light'] },
62 after: { type: 'array', items: { type: 'integer' }, description: 'Numbers of the tasks this one waits for.' },
63 },
64 required: ['title', 'tier'],
65 },
66 },
67 },
68 },
69} as const
70
71const STEP_SPEC = {
72 name: 'step',
73 description:
74 'For savvy-flow workers: report progress on your own task to the agents panel. ' +
75 'Right after reading the brief, call it with `total` (your plan in 3-8 steps) and `done: 0`; ' +
76 'call it again as each step finishes. Cheap and silent: it only draws a bar.',
77 inputSchema: {
78 type: 'object',
79 properties: {
80 done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
81 total: { type: 'integer', minimum: 1, description: 'Steps planned; may change if the plan changes.' },
82 note: { type: 'string', description: 'The step in progress, a few words.' },
83 },
84 required: ['done'],
85 },
86} as const
87
88async function settingsLang($: EngineInterface): Promise<Lang | null> {
89 try {
90 const settings = (await $.settings.read()) as Record<string, unknown>
91 return langOf(settings.language)
92 } catch (error) {
93 $.ui.log(`savvy-progress: settings unreadable, using the locale (${String(error)})`, { to: 'debug' })
94 return null
95 }
96}
97
98// The plugin's own option, then MODZ_LANG, then Claude Code's `language`, then the locale; English when none says.
99// The interface language (kit/lang.ts). Spelled out: the engine admits an env read by a literal name only.
100async function detectLang($: EngineInterface, option: unknown): Promise<Lang> {
101 if (isLang(option)) return option
102 const fromModz = langOf(await $.env.get('MODZ_LANG').catch(() => undefined))
103 if (fromModz) return fromModz
104 const fromSettings = await settingsLang($)
105 if (fromSettings) return fromSettings
106 const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
107 return langOf(locale) ?? 'en'
108}
109
110// Where the 5h window and the session's dollars stood when the session started.
111async function seedLimit($: EngineInterface): Promise<void> {
112 try {
113 const usage = await $.session.usage()
114 const reading = readingOf(usage.rateLimits, usage.cost?.usd)
115 const prev = await read($, limit)
116 if (reading && !isSame(trackLimit(prev, reading), prev)) await update($, limit, p => trackLimit(p, reading))
117 } catch (error) {
118 $.ui.log(`savvy-progress: no usage yet, the 5h share waits for a measure (${String(error)})`, { to: 'debug' })
119 }
120}
121
122async function scanDir($: EngineInterface, dir: string, source: string): Promise<AgentType[]> {
123 const found: AgentType[] = []
124 try {
125 for (const entry of await $.fs.list(dir)) {
126 if (!entry.name.endsWith('.md')) continue
127 const type = parseAgentFile(await $.fs.read(`${dir}/${entry.name}`), source)
128 if (type) found.push(type)
129 }
130 } catch (error) {
131 $.ui.log(`savvy-progress: no agent files in ${dir} (${String(error)})`, { to: 'debug' })
132 }
133 return found
134}
135
136// The agent types "Yeni görev" can start from files: the project's, then the user's.
137async function scanTypes($: EngineInterface): Promise<void> {
138 const home = await $.env.get('HOME')
139 const project = await scanDir($, AGENTS_DIR, 'project')
140 const user = home ? await scanDir($, `${home}/${AGENTS_DIR}`, 'user') : []
141 const found = [...project, ...user]
142 const known = await read($, types)
143 if (found.length && !isSame(mergeTypes(known, found), known)) await update($, types, current => mergeTypes(current, found))
144}
145
146// Folds the engine's own agent list into ours: an agent spawned before this mod loaded appears,
147// one whose end event never came is closed. A failure only costs this beat, never the session.
148async function syncRoster($: EngineInterface): Promise<void> {
149 try {
150 const roster = await $.agent.list()
151 const at = await $.clock.now()
152 const prev = await read($, agents)
153 if (reconcile(prev, roster, at) !== prev) await update($, agents, list => reconcile(list, roster, at))
154 } catch (error) {
155 $.ui.log(`savvy-progress: no agent list this beat (${String(error)})`, { to: 'debug' })
156 }
157}
158
159// One beat a second: the roster is folded in, and the running agents' clocks tick while any runs.
160function startBeat($: EngineInterface): void {
161 $.clock.every(TICK_MS, () => {
162 void (async () => {
163 await syncRoster($)
164 const list = await read($, agents)
165 if (!list.some(isRunning)) return
166 const at = await $.clock.now()
167 await update($, now, () => at)
168 })()
169 })
170}
171
172// The MODZ hub's card for this mod (DESIGN.md §9): written when its words or tone moved, or always
173// when `isForced` (once per session start, so the card stands from the hub's first look).
174// presence.ts writes it too, after the other chats' beat.
175async function syncHub($: EngineInterface, isForced = false): Promise<void> {
176 const sources = { flow: await read($, flow), agents: await read($, agents), job: await read($, job), tasks: await read($, tasks) }
177 const { detail, tone } = hubCardOf({ ...sources, chats: await read($, chats) })
178 const prev = await read($, hub)
179 if (!isForced && prev.detail === detail && prev.tone === tone) return
180 const at = await $.clock.now()
181 await update($, hub, () => ({ pane: HUB_PANE, detail, tone, at }))
182}
183
184// The values the card is derived from; `now`, `panel`, `limit` and `hub` itself never move it.
185// A card that failed to sync never holds up the write it follows.
186function onHubSources(on: On): void {
187 on('state.set', { plugin: 'savvy-progress', key: 'flow' }, async ($, e, next) => {
188 const done = await next(e)
189 await syncHub($)
190 return done
191 }).catch(($, e, next) => next(e))
192 on('state.set', { plugin: 'savvy-progress', key: 'agents' }, async ($, e, next) => {
193 const done = await next(e)
194 await syncHub($)
195 return done
196 }).catch(($, e, next) => next(e))
197 on('state.set', { plugin: 'savvy-progress', key: 'job' }, async ($, e, next) => {
198 const done = await next(e)
199 await syncHub($)
200 return done
201 }).catch(($, e, next) => next(e))
202 on('state.set', { plugin: 'savvy-progress', key: 'tasks' }, async ($, e, next) => {
203 const done = await next(e)
204 await syncHub($)
205 return done
206 }).catch(($, e, next) => next(e))
207}
208
209export const register: Register = (on, options) => {
210 on('session.start', async ($, e, next) => {
211 setLang(await detectLang($, options.language))
212 const started = await next(e)
213 await $.tool.register(PROGRESS_SPEC)
214 await $.tool.register(STEP_SPEC)
215 await $.command.register({
216 name: 'agents-info',
217 description: t().command,
218 })
219 startBeat($)
220 await seedLimit($)
221 await scanTypes($)
222 await syncHub($, true)
223 return started
224 })
225
226 onEvents(on)
227 onPane(on)
228 onDispatch(on)
229 onBand(on, { isCrabInBand: options.crabInBand === true })
230 onPresence(on)
231 onHubSources(on)
232 onTranscript(on)
233}
234hooks/agent-types.ts 54 lines1import type { AgentType, Draft } from '../types'
2import { clip } from './format'
3import { t } from './i18n'
4
5// The agent types "Yeni görev" can start: the engine's built-ins first, then each type the engine
6// offers the model (plugins' agents, settings' agents), then the agents/*.md files of the project
7// and the user. A name is listed once, the first source to name it kept.
8
9const DESCRIPTION_MAX = 160
10export const DEFAULT_TYPE = 'general-purpose'
11// The Agent call's own `description`: a few words, cut from the task.
12export const TASK_TITLE_MAX = 40
13
14export const BUILTIN_TYPES: AgentType[] = [
15 { name: DEFAULT_TYPE, description: 'Research, search and multi-step work', source: 'built-in' },
16 { name: 'Explore', description: 'Fast, read-only code search', source: 'built-in' },
17 { name: 'Plan', description: 'Drafts an implementation plan', source: 'built-in' },
18]
19
20export const blankDraft = (): Draft => ({ isOpen: false, type: DEFAULT_TYPE })
21
22/** An agents/*.md file's `name` and `description` from its front matter, or null when it has no name. */
23export function parseAgentFile(text: string, source: string): AgentType | null {
24 const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)
25 if (!front) return null
26 const field = (key: string) => new RegExp(`^${key}:\\s*(.+)$`, 'm').exec(front[1] ?? '')?.[1]?.trim().replace(/^["']|["']$/g, '')
27 const name = field('name')
28 if (!name) return null
29 return { name, description: clip(field('description') ?? '', DESCRIPTION_MAX), source }
30}
31
32/** `current` with each type of `found` it does not name yet; the same array when none is new. */
33export function mergeTypes(current: AgentType[], found: AgentType[]): AgentType[] {
34 const fresh = found.filter((type, i) => !current.some(c => c.name === type.name) && found.findIndex(f => f.name === type.name) === i)
35 return fresh.length ? [...current, ...fresh] : current
36}
37
38/** The type the form shows: the one picked, else the first known. */
39export const chosenOf = (types: AgentType[], draft: Draft | undefined): AgentType | undefined =>
40 types.find(type => type.name === draft?.type) ?? types[0]
41
42/** What a type does, in the interface's language for a built-in. */
43export const describe = (type: AgentType): string => t().builtins[type.name] ?? (type.description || t().noDescription)
44
45/** Where a type comes from, in the interface's words. */
46export const sourceWord = (source: string): string => t().sources[source] ?? source
47
48/** What an `agent.offer` names, as a type the form lists. */
49export const offered = (e: { agent: string; description: string; source: string }): AgentType => ({
50 name: e.agent,
51 description: clip(e.description.split('\n')[0] ?? '', DESCRIPTION_MAX),
52 source: e.source,
53})
54hooks/agents.ts 147 lines1import type { AgentRun, ThreadTurn } from '../types'
2import { clip, norm, toolDetail, toolName, toolTarget } from './format'
3import { nextLook } from './identity'
4import { costOf, tokensOf, windowOf } from './pricing'
5import type { Usage } from './pricing'
6import { MAX_RUNS } from './state'
7import { applyTaskTool } from './tasks'
8
9// Pure updates of the agent list; the hooks in events.ts feed them what the engine reports.
10
11const BRIEF_MAX = 800
12const ANSWER_MAX = 1500
13// The conversation keeps its latest turns; the task it was handed stays above them as `brief`.
14const THREAD_MAX = 12
15
16export type Spawn = {
17 id: string
18 agentId?: string
19 toolUseId?: string
20 parentId?: string
21 type: string
22 description: string
23 model: string
24 effort?: string
25 brief?: string
26 isAsked?: boolean
27 at: number
28}
29
30export function addRun(list: AgentRun[], spawn: Spawn): AgentRun[] {
31 const same = list.filter(a => spawn.description && norm(a.description) === norm(spawn.description))
32 const run: AgentRun = {
33 id: spawn.id,
34 agentId: spawn.agentId,
35 toolUseId: spawn.toolUseId,
36 parentId: spawn.parentId,
37 type: spawn.type,
38 description: spawn.description,
39 model: spawn.model,
40 effort: spawn.effort,
41 brief: spawn.brief?.trim() ? clip(spawn.brief.trim(), BRIEF_MAX) : undefined,
42 isAsked: spawn.isAsked,
43 status: 'running',
44 startedAt: spawn.at,
45 contextTokens: 0,
46 contextMax: windowOf(spawn.model),
47 tokens: 0,
48 costUsd: 0,
49 steps: 0,
50 round: 1 + same.length,
51 // A run the roster met first keeps the look it arrived with.
52 look: list.find(a => a.id === spawn.id)?.look ?? nextLook(list),
53 toolCount: 0,
54 }
55 return [...list.filter(a => a.id !== run.id), run].slice(-MAX_RUNS)
56}
57
58const withTurn = (a: AgentRun, turn: ThreadTurn): AgentRun => ({ ...a, thread: [...(a.thread ?? []), turn].slice(-THREAD_MAX) })
59
60/** A follow-up the person sent from the card: it waits under the conversation for the next answer. */
61export const askedAgain = (a: AgentRun, text: string, at: number): AgentRun => withTurn(a, { from: 'you', text: clip(text.trim(), BRIEF_MAX), at })
62
63export const patchRun = (list: AgentRun[], agentId: string, fn: (a: AgentRun) => AgentRun): AgentRun[] =>
64 list.map(a => (a.agentId === agentId ? fn(a) : a))
65
66type StepFacts = { model: string; effort: unknown; usage: Usage }
67
68// One model request of a subagent: its context now, its tokens and cost so far.
69export const stepUsage = (a: AgentRun, facts: StepFacts): AgentRun => ({
70 ...a,
71 model: facts.model,
72 effort: typeof facts.effort === 'string' ? facts.effort : a.effort,
73 status: 'running',
74 endedAt: undefined,
75 endReason: undefined,
76 contextTokens: tokensOf(facts.usage),
77 contextMax: windowOf(facts.model),
78 tokens: a.tokens + tokensOf(facts.usage),
79 costUsd: a.costUsd + costOf(facts.model, facts.usage),
80 steps: a.steps + 1,
81})
82
83type EndFacts = { reason: string; at: number; answer?: string; usage?: Usage & { model?: string } }
84
85export const endRun = (a: AgentRun, facts: EndFacts): AgentRun => {
86 const isAnswered = facts.reason === 'answer'
87 // A run whose steps went unseen still gets the turn's own sum.
88 const usage = a.steps === 0 ? facts.usage : undefined
89 const model = usage?.model || a.model
90 const answer = facts.answer?.trim() ? clip(facts.answer.trim(), ANSWER_MAX) : undefined
91 const told = isAnswered && answer ? withTurn(a, { from: 'agent', text: answer, at: facts.at }) : a
92 return {
93 ...told,
94 status: isAnswered ? 'done' : 'failed',
95 endReason: isAnswered ? undefined : facts.reason,
96 endedAt: facts.at,
97 answer: answer ?? a.answer,
98 ...(usage ? { model, tokens: tokensOf(usage), costUsd: costOf(model, usage) } : {}),
99 }
100}
101
102// What the agent is doing now and how many tool calls it made.
103export const toolActivity = (a: AgentRun, call: Record<string, unknown>): AgentRun => ({
104 ...a,
105 toolCount: (a.toolCount || 0) + 1,
106 lastTool: toolName(String(call.tool ?? '')),
107 lastTarget: toolTarget(call) || undefined,
108 lastDetail: toolDetail(call) || undefined,
109})
110
111export const ownTasks = (a: AgentRun, call: Record<string, unknown>, result: unknown): AgentRun => ({
112 ...a,
113 todos: applyTaskTool(a.todos ?? [], call, result),
114})
115
116export type StepInput = { done?: number; total?: number; note?: string }
117
118// The worker's own report through the `step` tool.
119export const stepReport = (a: AgentRun, input: StepInput): AgentRun => {
120 const total = Math.max(0, Math.round(input.total ?? a.stepTotal ?? 0))
121 const done = Math.max(0, Math.round(input.done ?? a.stepDone ?? 0))
122 return { ...a, stepTotal: total, stepDone: total ? Math.min(total, done) : done, stepNote: input.note?.trim() || undefined }
123}
124
125/** Anything an agent's own loop reports counts as hearing from it: the quiet check reads this. */
126export const heard = (a: AgentRun, at: number): AgentRun => ({ ...a, heardAt: Math.max(a.heardAt ?? 0, at) })
127
128export const isRunning = (a: AgentRun): boolean => a.status === 'running'
129
130export const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
131
132export const ctxOf = (a: AgentRun): number =>
133 a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0
134
135export type Totals = { cost: number; tokens: number; time: number }
136
137export const totals = (list: AgentRun[], at: number): Totals => {
138 if (!list.length) return { cost: 0, tokens: 0, time: 0 }
139 const start = Math.min(...list.map(a => a.startedAt))
140 const end = Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt)))
141 return {
142 cost: list.reduce((sum, a) => sum + a.costUsd, 0),
143 tokens: list.reduce((sum, a) => sum + a.tokens, 0),
144 time: end - start,
145 }
146}
147hooks/band.tsx 222 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ThemeKey } from 'claude-code'
3
4import type { CrabMood } from '../types'
5import { bandSvg, BAND_H } from './band-svg'
6import { isOwnOpen } from './hub'
7import { isOffStage, shownOf } from './stage'
8import { t } from './i18n'
9import type { JobView } from './job'
10import { deriveJob, percentText } from './job'
11import { isIdleMood, restingCrab } from './mood'
12import { FACES } from './mood-svg'
13import { motion } from './pane-motion'
14import { pinned } from './pin'
15import { HUB_PANE, PANE } from './state'
16
17// State refs live in each file that reads them: the scan follows literals, not imports.
18const agents = atom({ plugin: 'savvy-progress', key: 'agents' } as const, [])
19const flow = atom({ plugin: 'savvy-progress', key: 'flow' } as const, null)
20const job = atom({ plugin: 'savvy-progress', key: 'job' } as const, null)
21const tasks = atom({ plugin: 'savvy-progress', key: 'tasks' } as const, [])
22const now = atom({ plugin: 'savvy-progress', key: 'now' } as const, 0)
23const crab = atom({ plugin: 'savvy-progress', key: 'crab' } as const, restingCrab(0))
24
25type On = Parameters<Register>[0]
26type Ui = Record<string, any>
27
28// About 8 CSS px per reported column; the crew count, the dismiss and their gaps sit beside the Svg.
29const PX_PER_COL = 8
30const BUTTONS_PX = 96
31const DEFAULT_COLS = 100
32// Terminal cells: the dot and its space, the two buttons, the gaps between the row's parts.
33const TERM = { dot: 2, buttons: 12, gaps: 5, titleMin: 8, meterMin: 8, meterMax: 40, segMin: 3 } as const
34
35// The meter's unit colours on the terminal (DESIGN.md §2: ok→success, danger→error, run→permission).
36type Cell = { key: ThemeKey | null; glyph: string }
37const CELL: Record<string, Cell> = {
38 ok: { key: 'success', glyph: '━' },
39 danger: { key: 'error', glyph: '━' },
40 run: { key: 'permission', glyph: '━' },
41 wait: { key: null, glyph: '─' },
42}
43
44const unitKind = (view: JobView, i: number): string => {
45 if (i < view.done - view.failed) return 'ok'
46 if (i < view.done) return 'danger'
47 return i < view.done + view.active ? 'run' : 'wait'
48}
49
50const pillKey = (view: JobView): ThemeKey => {
51 if (!view.isFinished) return 'permission'
52 return view.failed ? 'warning' : 'success'
53}
54
55// Tells the hub this pane now holds the panel, so the hub and every other pane step aside (kit/stage.ts).
56async function reportShown($: EngineInterface, at: number): Promise<void> {
57 try {
58 if ((await $.state.get({ plugin: 'modz', key: 'isOn' })).value !== true) return
59 await $.state.set({ plugin: 'savvy-progress', key: 'shown' }, shownOf(HUB_PANE, at))
60 } catch (cause) {
61 $.ui.log(`savvy-progress: could not tell MODZ the pane is shown: ${String(cause)}`, { to: 'debug' })
62 }
63}
64
65// Opens the İlerleme pane, or brings it forward when it is up already; never closes it.
66async function openPane($: EngineInterface): Promise<void> {
67 const at = await $.clock.now()
68 await update($, now, () => at)
69 motion.reset()
70 const opened = await $.ui.open({ id: PANE, title: t().pane, focus: true })
71 if (opened.isPlaced) await reportShown($, at)
72}
73
74// /agents-info and the crew button: open the pane, or close it when it is up; true when it ends up open.
75async function togglePane($: EngineInterface): Promise<boolean> {
76 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
77 if (isOpen) {
78 await $.ui.close({ id: PANE })
79 return false
80 }
81 await openPane($)
82 return true
83}
84
85// Hides the band until the next request launches agents or reports progress.
86async function dismiss($: EngineInterface): Promise<void> {
87 await update($, flow, () => null)
88 await update($, job, prev => (prev ? { ...prev, isSealed: true, isHidden: true } : prev))
89}
90
91function buttons(ui: Ui, $: EngineInterface, view: JobView) {
92 const { Button } = ui
93 return [
94 <Button key="savvy-agents" label={`×${view.crew}`} plain onPress={() => void togglePane($)} />,
95 <Button key="savvy-dismiss" label="✕" plain role="dismiss" onPress={() => dismiss($)} />,
96 ]
97}
98
99/** `mood` is the home crab's while it sits in the row: only with `crabInBand` on and the session idle. */
100type BandInput = { view: JobView; cols: number; mood?: CrabMood }
101
102function desktopBand(ui: Ui, $: EngineInterface, { view, cols, mood }: BandInput) {
103 const { Box, Svg } = ui
104 const drawing = bandSvg(view, (cols || DEFAULT_COLS) * PX_PER_COL - BUTTONS_PX, mood)
105 return (
106 <Box key="savvy-band" flexDirection="row" alignItems="center" columnGap={1}>
107 <Svg source={drawing.source} alt={`${view.title}: ${view.label}, ${percentText(view)}`} width={drawing.width} height={BAND_H} />
108 {buttons(ui, $, view)}
109 </Box>
110 )
111}
112
113// One run of cells per unit, a space between units while each gets a few cells; else one solid run.
114const termCells = (view: JobView, width: number): Cell[] => {
115 const units = Math.max(1, view.units)
116 const per = Math.floor((width - (units - 1)) / units)
117 const gap: Cell = { key: null, glyph: ' ' }
118 if (view.units > 0 && per >= TERM.segMin) {
119 return Array.from({ length: units }, (_, i) => [...(i ? [gap] : []), ...Array<Cell>(per).fill(CELL[unitKind(view, i)] ?? gap)]).flat()
120 }
121 if (view.units < 1) return Array<Cell>(width).fill(CELL.wait ?? gap)
122 return Array.from({ length: width }, (_, i) => CELL[unitKind(view, Math.floor((i * units) / width))] ?? gap)
123}
124
125function meterText(ui: Ui, cells: Cell[]) {
126 const { Text } = ui
127 const runs: { key: ThemeKey | null; text: string }[] = []
128 for (const cell of cells) {
129 const last = runs.at(-1)
130 if (last && last.key === cell.key) last.text += cell.glyph
131 else runs.push({ key: cell.key, text: cell.glyph })
132 }
133 return <Text>{runs.map((run, i) => (run.key ? <Text key={`m${i}`} color={run.key}>{run.text}</Text> : <Text key={`m${i}`} dimColor>{run.text}</Text>))}</Text>
134}
135
136// Widths in cells: the meter gives way to its minimum first, then the title, then the meter goes.
137const termPlan = (view: JobView, cols: number): { titleW: number; meterW: number } => {
138 const avail = cols - TERM.dot - view.label.length - percentText(view).length - TERM.buttons - TERM.gaps
139 const natural = view.title.length
140 if (avail - natural >= TERM.meterMin) return { titleW: natural, meterW: Math.min(TERM.meterMax, avail - natural) }
141 const titleW = Math.max(TERM.titleMin, avail - TERM.meterMin)
142 const meterW = avail - titleW
143 return meterW >= TERM.meterMin ? { titleW, meterW } : { titleW: Math.max(TERM.titleMin, avail + 1), meterW: 0 }
144}
145
146function terminalBand(ui: Ui, $: EngineInterface, { view, cols, mood }: BandInput) {
147 const { Box, Text } = ui
148 const face = mood ? FACES[mood] : ''
149 const plan = termPlan(view, (cols || DEFAULT_COLS) - (face ? face.length + 1 : 0))
150 return (
151 <Box key="savvy-band" flexDirection="row" columnGap={1}>
152 <Box width={plan.titleW + TERM.dot} flexShrink={0}>
153 <Text color={pillKey(view)}>● </Text>
154 <Text bold wrap="truncate-end">{view.title}</Text>
155 </Box>
156 <Text color={pillKey(view)}>{view.label}</Text>
157 {plan.meterW > 0 && meterText(ui, termCells(view, plan.meterW))}
158 <Text bold>{percentText(view)}</Text>
159 {face !== '' && <Text dimColor>{face}</Text>}
160 {buttons(ui, $, view)}
161 </Box>
162 )
163}
164
165async function viewOf($: EngineInterface): Promise<JobView | null> {
166 return deriveJob({ flow: await read($, flow), agents: await read($, agents), job: await read($, job), tasks: await read($, tasks) })
167}
168
169// The ways in to the pane: the command, and the MODZ hub's open request (DESIGN.md §9).
170function onOpen(on: On): void {
171 on('command.run', { command: 'agents-info' }, async $ => {
172 const isOpen = await togglePane($)
173 return { text: isOpen ? t().opened : t().closed }
174 })
175
176 on('state.set', { plugin: 'modz', key: 'open' }, async ($, e, next) => {
177 const done = await next(e)
178 if (isOwnOpen(e.value)) await openPane($)
179 return done
180 }).catch(($, e, next) => next(e))
181
182 // Another pane took the MODZ panel: this one steps aside rather than wait as a tab.
183 on('state.set', { plugin: 'modz', key: 'stage' }, async ($, e, next) => {
184 const done = await next(e)
185 if (isOffStage(e.value, HUB_PANE)) await $.ui.close({ id: PANE })
186 return done
187 }).catch(($, e, next) => next(e))
188}
189
190// The band contract: the engine's and other plugins' rows are drawn first and kept;
191// progress stacks above them, and a survey gets the band to itself.
192// The home crab's mood for the row, or none: it sits there only when asked for and while idle.
193async function bandMood($: EngineInterface, isCrabInBand: boolean): Promise<CrabMood | undefined> {
194 if (!isCrabInBand) return undefined
195 const { mood } = await read($, crab)
196 return isIdleMood(mood) ? mood : undefined
197}
198
199export type BandOptions = { isCrabInBand: boolean }
200
201export function onBand(on: On, opts: BandOptions): void {
202 onOpen(on)
203
204 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
205 if (e.props.hasSurvey) return next(e)
206 const theirs = await next(e)
207 const view = await viewOf($)
208 if (!view) return theirs
209 const table = $.ui.resolve(e)
210 const ui = table as Ui
211 const { Box } = ui
212 const input = { view, cols: e.props.bodyColumns, mood: await bandMood($, opts.isCrabInBand) }
213 const mine = e.surface !== 'terminal' && 'Svg' in ui ? desktopBand(ui, $, input) : terminalBand(ui, $, input)
214 return (
215 <Box flexDirection="column">
216 {pinned(e, mine)}
217 {theirs}
218 </Box>
219 )
220 })
221}
222hooks/dispatch.tsx 59 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { AgentType, Draft } from '../types'
5import { BUILTIN_TYPES, chosenOf, describe, mergeTypes, offered, sourceWord } from './agent-types'
6import { sectionSvg, SECTION_SVG_H } from './card-svg'
7import { t } from './i18n'
8import { upper } from './lang'
9import { picture } from './pane-motion'
10import type { Ui } from './rows'
11import type { Layout, PaneHandlers } from './sections'
12
13// "Yeni görev": pick an agent type, describe the task, start it. This file draws the form and keeps
14// the catalogue's live source; the start (`$.agent.spawn`) is in pane.tsx and the agent files are
15// read in register.tsx, since the engine follows `$` only into functions of the hook's own file.
16
17// State refs live in each file that reads them: the scan follows literals, not imports.
18const types = atom({ plugin: 'savvy-progress', key: 'types' } as const, BUILTIN_TYPES)
19
20type On = Parameters<Register>[0]
21
22export type DraftView = { draft: Draft | undefined; types: AgentType[]; layout: Layout }
23
24function draftHead(ui: Ui, layout: Layout) {
25 const { Text } = ui
26 const label = t().newTask
27 if (!layout.isDesktop) return <Text bold color="permission">{`● ${upper(label)}`}</Text>
28 return picture(ui, { key: 'draft-head', at: 'draft-head', pic: { source: sectionSvg(layout.inner, { label, dot: 'run' }), alt: label, width: layout.inner, height: SECTION_SVG_H } })
29}
30
31/** The form: a type picker with what the type does, the task field, a refusal if one came, "Vazgeç". */
32export function draftTree(ui: Ui, view: DraftView, on: PaneHandlers) {
33 const { Box, Button, Input, Select, Text } = ui
34 const s = t()
35 const chosen = chosenOf(view.types, view.draft)
36 const frame = view.layout.isDesktop ? { borderStyle: 'round', paddingX: 2, paddingY: 1 } : {}
37 return (
38 <Box key="draft" flexDirection="column" rowGap={1} {...frame}>
39 {draftHead(ui, view.layout)}
40 {!chosen && <Text dimColor>{s.noTypes}</Text>}
41 {chosen && Select && <Select key="dispatch-type" label={s.agentLabel} options={view.types.map(type => ({ value: type.name, label: type.name }))} value={chosen.name} onSelect={on.onPickType} />}
42 {chosen && <Text dimColor wrap="truncate-end">{`${sourceWord(chosen.source)} · ${describe(chosen)}`}</Text>}
43 {chosen && Input && <Input key="dispatch-task" label={s.taskLabel} placeholder={s.taskHint} value="" submitLabel={s.start} autoFocus onSubmit={on.onStartTask} />}
44 {view.draft?.notice && <Text color="warning">{view.draft.notice}</Text>}
45 <Box flexDirection="row"><Button key="dispatch-cancel" plain dimColor label={s.cancel} onPress={on.onCancelTask} /></Box>
46 </Box>
47 )
48}
49
50// The catalogue's live source: every type the engine offers the model.
51export function onDispatch(on: On): void {
52 on('agent.offer', async ($, e, next) => {
53 const answer = await next(e)
54 const type = offered(e)
55 if (!(await read($, types)).some(known => known.name === type.name)) await update($, types, current => mergeTypes(current, [type]))
56 return answer
57 }).catch(($, e, next) => next(e))
58}
59hooks/events.ts 170 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { StepInput } from './agents'
5import { addRun, endRun, heard, isRunning, ownTasks, patchRun, stepReport, stepUsage, toolActivity } from './agents'
6import { isNewFlow, mergeFlow } from './flow'
7import { closeEvent, openEvent } from './history'
8import { readingOf, trackLimit } from './limit'
9import type { LimitReading } from './limit'
10import type { ProgressInput } from './flow'
11import { deriveJob, joinJob, sealJob } from './job'
12import { STEP_TOOL, TOOL } from './state'
13import { TASK_TOOLS, applyTaskTool, openTasks } from './tasks'
14import { isSame } from './same'
15import { touchFile } from './touched'
16
17// State refs live in each file that reads them: the scan follows literals, not imports.
18const agents = atom({ plugin: 'savvy-progress', key: 'agents' } as const, [])
19const flow = atom({ plugin: 'savvy-progress', key: 'flow' } as const, null)
20const job = atom({ plugin: 'savvy-progress', key: 'job' } as const, null)
21const now = atom({ plugin: 'savvy-progress', key: 'now' } as const, 0)
22const tasks = atom({ plugin: 'savvy-progress', key: 'tasks' } as const, [])
23const limit = atom({ plugin: 'savvy-progress', key: 'limit' } as const, null)
24
25type On = Parameters<Register>[0]
26
27// The effort an Agent call asked for, held from the call until its spawn picks it up.
28// Lost on a reload, harmlessly: the first model request reports the effort again.
29const launchEffort = new Map<string, string>()
30
31async function stamp($: EngineInterface): Promise<number> {
32 const at = await $.clock.now()
33 await update($, now, () => at)
34 return at
35}
36
37// The orchestrator's report and the workers' own steps.
38function onReports(on: On): void {
39 on('tool.call', { tool: TOOL }, async ($, e) => {
40 const input = e as unknown as ProgressInput
41 const prev = await read($, flow)
42 // A new flow starts with a clean list; agents still running stay.
43 if (isNewFlow(prev, input) && input.title !== undefined) await update($, agents, list => list.filter(isRunning))
44 const next = await update($, flow, p => mergeFlow(p, input))
45 const view = deriveJob({ flow: next, agents: [], job: null, tasks: [] })
46 return { result: `ok: ${view?.label ?? ''}` }
47 })
48
49 // A worker's own progress: the call runs in the worker's loop, so agentId names it.
50 on('tool.call', { tool: STEP_TOOL }, async ($, e) => {
51 const agentId = e.agentId
52 if (!agentId) return { result: 'ignored: only subagents report steps' }
53 const input = e as unknown as StepInput
54 const at = await $.clock.now()
55 await update($, agents, list => patchRun(list, agentId, a => heard(stepReport(a, input), at)))
56 return { result: 'ok' }
57 })
58}
59
60// Launches: the Agent call's effort, the spawn itself, and where one request ends.
61function onLaunches(on: On): void {
62 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
63 if (typeof e.effort === 'string' && e.tool_use_id) launchEffort.set(e.tool_use_id, e.effort)
64 try {
65 return await next(e)
66 } finally {
67 launchEffort.delete(e.tool_use_id ?? '')
68 }
69 })
70
71 on('agent.spawn', async ($, e, next) => {
72 const started = await next(e)
73 if (started.deny !== undefined) return started
74 const at = await stamp($)
75 const id = started.agentId ?? e.tool_use_id
76 const spawn = { id, agentId: started.agentId, toolUseId: e.tool_use_id, parentId: e.parentAgentId, at }
77 const who = { type: e.subagentType, description: e.description, brief: e.prompt, model: started.model, effort: launchEffort.get(e.tool_use_id) }
78 await update($, agents, list => addRun(list, { ...spawn, ...who }))
79 // Only the main loop's launches make up the request's job; nested ones show in the panel.
80 if (!e.parentAgentId) await update($, job, prev => joinJob(prev, id, at))
81 return started
82 })
83
84 // A new request (anything but a background task's notice) closes a job with nothing running.
85 on('prompt.submit', async ($, e, next) => {
86 const result = await next(e)
87 if (e.origin.kind === 'task-notification') return result
88 const list = await read($, agents)
89 // Each write redraws its readers, so one that would change nothing is left out.
90 const before = await read($, job)
91 const kept = isSame(sealJob(before, list), before) ? before : await update($, job, prev => sealJob(prev, list))
92 const open = await read($, tasks)
93 if ((!kept || kept.isSealed) && !isSame(openTasks(open), open)) await update($, tasks, openTasks)
94 if ((await read($, flow))?.isFinished) await update($, flow, f => (f?.isFinished ? null : f))
95 return result
96 })
97}
98
99// What each loop is doing: its tool calls and task lists.
100function onActivity(on: On): void {
101 on('tool.call', async ($, e, next) => {
102 const call = e as unknown as Record<string, unknown>
103 const name = String(e.tool)
104 const agentId = e.agentId
105 const id = typeof call.tool_use_id === 'string' ? call.tool_use_id : undefined
106 const isTracked = name !== STEP_TOOL
107 if (agentId && isTracked) {
108 const at = await $.clock.now()
109 const facts = { call, at, id }
110 await update($, agents, list => patchRun(list, agentId, a => heard(openEvent(touchFile(toolActivity(a, call), facts), facts), at)))
111 }
112 const result = await next(e)
113 if (agentId && isTracked) {
114 const facts = { id, tool: name, at: await $.clock.now(), isError: result.deny !== undefined || result.isError === true }
115 await update($, agents, list => patchRun(list, agentId, a => heard(closeEvent(a, facts), facts.at)))
116 }
117 if (!TASK_TOOLS.includes(name) || result.deny !== undefined || result.isError) return result
118 if (agentId) await update($, agents, list => patchRun(list, agentId, a => ownTasks(a, call, result.result)))
119 else await update($, tasks, list => applyTaskTool(list, call, result.result))
120 return result
121 })
122}
123
124// Each loop's model requests and its end.
125function onSteps(on: On): void {
126 // Each model request of a subagent: live context, tokens and cost.
127 on('turn.step', async function* ($, e, next) {
128 const result = yield* next(e)
129 const agentId = e.agentId
130 const usage = result.usage
131 if (!agentId || !usage) return result
132 const facts = { model: usage.model || e.model, effort: e.effort, usage }
133 const at = await $.clock.now()
134 await update($, agents, list => patchRun(list, agentId, a => heard(stepUsage(a, facts), at)))
135 return result
136 })
137
138 on('turn.complete', async ($, e, next) => {
139 const agentId = e.agentId
140 if (agentId) {
141 const at = await stamp($)
142 await update($, agents, list => patchRun(list, agentId, a => endRun(a, { reason: e.reason, at, answer: e.answer, usage: e.usage })))
143 }
144 return next(e)
145 })
146}
147
148// A measure that moved nothing is not written: every write redraws the band and the pane.
149async function follow($: EngineInterface, reading: LimitReading | null): Promise<void> {
150 if (!reading) return
151 const prev = await read($, limit)
152 if (!isSame(trackLimit(prev, reading), prev)) await update($, limit, p => trackLimit(p, reading))
153}
154
155// Each measure moves the 5h window and the session's dollars, for each agent's share of the limit.
156function onLimit(on: On): void {
157 on('session.measure', async ($, e, next) => {
158 await follow($, readingOf(e.rateLimits, e.cost?.usd))
159 return next(e)
160 })
161}
162
163export function onEvents(on: On): void {
164 onLimit(on)
165 onReports(on)
166 onLaunches(on)
167 onActivity(on)
168 onSteps(on)
169}
170hooks/flow.ts 61 lines1import type { AgentRun, Flow, Phase, PlannedTask } from '../types'
2import { norm } from './format'
3
4export const PHASES: readonly Phase[] = ['plan', 'design', 'delegate', 'review', 'close']
5
6export type ProgressInput = {
7 title?: string
8 total?: number
9 done?: number
10 phase?: Phase
11 finished?: boolean
12 tasks?: { title?: string; tier?: string; after?: number[] }[]
13}
14
15export type Planned = PlannedTask & { n: number }
16
17export const blankFlow = (): Flow => ({
18 title: 'savvy-flow',
19 total: 0,
20 done: 0,
21 phase: 'plan',
22 isFinished: false,
23 tasks: [],
24})
25
26export const isNewFlow = (prev: Flow | null, input: ProgressInput): boolean =>
27 !prev || prev.isFinished || (input.title !== undefined && input.title.trim() !== prev.title)
28
29const cleanTasks = (tasks: ProgressInput['tasks']): PlannedTask[] | undefined =>
30 tasks
31 ?.filter(task => task.title?.trim())
32 .map(task => ({
33 title: (task.title ?? '').trim(),
34 tier: (task.tier ?? '').replace(/^savvy-/, '').trim().toLowerCase(),
35 after: (task.after ?? []).filter(n => Number.isInteger(n) && n > 0),
36 }))
37
38export const mergeFlow = (prev: Flow | null, input: ProgressInput): Flow => {
39 // A new title means a new flow: never carry counters over from an earlier one.
40 const base = isNewFlow(prev, input) || !prev ? blankFlow() : { ...blankFlow(), ...prev }
41 const tasks = cleanTasks(input.tasks) ?? base.tasks
42 const total = Math.max(0, Math.round(input.total ?? (input.tasks ? tasks.length : base.total)))
43 const done = Math.min(total || Infinity, Math.max(0, Math.round(input.done ?? base.done)))
44 const phase = input.phase && PHASES.includes(input.phase) ? input.phase : base.phase
45 return {
46 title: input.title?.trim() || base.title,
47 total,
48 done,
49 phase: input.finished ? 'close' : phase,
50 isFinished: input.finished === true,
51 tasks,
52 }
53}
54
55// Planned tasks no run has picked up yet: a run is matched to its task by its description.
56export const plannedOf = (f: Flow | null, list: AgentRun[]): Planned[] => {
57 if (!f || f.isFinished) return []
58 const started = new Set(list.map(a => norm(a.description)))
59 return (f.tasks ?? []).map((task, i) => ({ ...task, n: i + 1 })).filter(task => !started.has(norm(task.title)))
60}
61hooks/hub.ts 51 lines1import type { AgentRun, Flow, HubTone, Job, OtherChat, TrackedTask } from '../types'
2import { isRunning } from './agents'
3import { waitingOf } from './chats'
4import { clip } from './format'
5import { t } from './i18n'
6import { deriveJob } from './job'
7import type { JobView } from './job'
8import { HUB_PANE } from './state'
9
10// The MODZ data path (DESIGN.md §9), its pure half: whether the hub's open request names this
11// pane, and what this mod's one-line card says. The hooks live where `$` is: band.tsx opens the
12// pane, register.tsx keeps the card.
13
14const DETAIL_MAX = 40
15const SEP = ' · '
16
17/** Whether a `modz.open` request names this mod's pane. */
18export const isOwnOpen = (value: unknown): boolean =>
19 typeof value === 'object' && value !== null && (value as { pane?: unknown }).pane === HUB_PANE
20
21export const toneOf = (view: JobView | null): HubTone => {
22 if (!view) return 'idle'
23 if (!view.isFinished) return 'live'
24 return view.failed ? 'warn' : 'ok'
25}
26
27/**
28 * "Görevler 3/4 · 2 ajan" ("Tasks 3/4 · 2 running") while agents run, the band's label alone otherwise, "" with no job;
29 * "· 1 chat waits" after it while another chat on this machine waits on the person. The job's part gives way first.
30 */
31export const hubDetailOf = (view: JobView | null, running: number, waiting = 0): string => {
32 const crew = running > 0 ? `${SEP}${t().hubCrew(running)}` : ''
33 const jobPart = view ? `${view.label}${crew}` : ''
34 const waits = waiting > 0 ? t().chats.hubWaits(waiting) : ''
35 if (!waits) return clip(jobPart, DETAIL_MAX)
36 if (!view) return clip(waits, DETAIL_MAX)
37 const room = DETAIL_MAX - waits.length - SEP.length
38 // Too long whole, the crew count goes before the label is cut.
39 const lead = jobPart.length <= room ? jobPart : clip(view.label, room)
40 return `${lead}${SEP}${waits}`
41}
42
43/** Everything the card is drawn from, as it is stored. */
44export type HubSources = { flow: Flow | null; agents: AgentRun[]; job: Job | null; tasks: TrackedTask[]; chats: OtherChat[] }
45
46/** The card's words and tone. */
47export const hubCardOf = (src: HubSources): { detail: string; tone: HubTone } => {
48 const view = deriveJob({ flow: src.flow, agents: src.agents, job: src.job, tasks: src.tasks })
49 return { detail: hubDetailOf(view, src.agents.filter(isRunning).length, waitingOf(src.chats).length), tone: toneOf(view) }
50}
51hooks/i18n.ts 35 lines1// English by default. The plugin's `language` option wins, then MODZ_LANG (the MODZ-wide switch), then
2// Claude Code's `language` setting, then the locale; register.tsx walks that order at session.start.
3import { setLang as setKitLang } from './lang'
4import { en } from './lang-en'
5import { ru } from './lang-ru'
6import { tr } from './lang-tr'
7import type { Lang, Strings } from './strings'
8
9export type { Lang, Strings } from './strings'
10
11const STRINGS: Record<Lang, Strings> = { tr, en, ru }
12
13// Module scope is fine here: session.start sets it again on every (re)load.
14let lang: Lang = 'en'
15
16export const t = (): Strings => STRINGS[lang]
17
18// The kit's language follows, so its `upper` capitalises the way the words are written.
19export const setLang = (next: Lang): void => {
20 lang = next
21 setKitLang(next)
22}
23
24export const langOf = (value: unknown): Lang | null => {
25 if (typeof value !== 'string' || !value.trim()) return null
26 if (/^(tr|turk|türk)/i.test(value.trim())) return 'tr'
27 if (/^(ru|russian|рус)/i.test(value.trim())) return 'ru'
28 return 'en'
29}
30
31export const isLang = (v: unknown): v is Lang => v === 'tr' || v === 'en' || v === 'ru'
32
33/** Every language's strings, for a test that checks none is missing a word. */
34export const ALL_STRINGS: Readonly<Record<Lang, Strings>> = STRINGS
35hooks/limit.ts 52 lines1import type { LimitTrack } from '../types'
2import { fixed } from './format'
3
4// Each agent's share of the 5h limit. The engine reports the window's percent and the session's
5// dollars, never a per-agent split, so the session's own rate (points of the window per dollar
6// since it started) turns each agent's ≈$ into ≈points. Below a point of movement the rate is
7// noise, so nothing is shown rather than a made-up number.
8
9export const FIVE_HOUR = 'five_hour'
10const MIN_POINTS = 0.5
11const MIN_USD = 0.05
12const TENTH = 0.1
13const ONE_DECIMAL_BELOW = 10
14
15export type LimitReading = { percent: number; usd: number; resetsAt?: string }
16
17type RateLimitLike = { kind: string; percentUsed: number; resetsAt?: string }
18
19export const readingOf = (rateLimits: readonly RateLimitLike[], usd: number | undefined): LimitReading | null => {
20 const window = rateLimits.find(limit => limit.kind === FIVE_HOUR)
21 if (!window || usd === undefined) return null
22 return { percent: window.percentUsed, usd, resetsAt: window.resetsAt }
23}
24
25const rateBetween = (track: LimitTrack): number | null => {
26 const points = track.percent - track.basePercent
27 const usd = track.usd - track.baseUsd
28 return points >= MIN_POINTS && usd >= MIN_USD ? points / usd : null
29}
30
31// A window that reset starts a new base; the rate learned before it carries over.
32export const trackLimit = (prev: LimitTrack | null, reading: LimitReading): LimitTrack => {
33 if (!prev) return { basePercent: reading.percent, baseUsd: reading.usd, percent: reading.percent, usd: reading.usd, resetsAt: reading.resetsAt }
34 const hasReset = reading.resetsAt !== undefined && prev.resetsAt !== undefined && reading.resetsAt !== prev.resetsAt
35 if (!hasReset) return { ...prev, percent: reading.percent, usd: reading.usd, resetsAt: reading.resetsAt ?? prev.resetsAt }
36 const carried = rateBetween(prev) ?? prev.carriedRate
37 return { basePercent: reading.percent, baseUsd: reading.usd, percent: reading.percent, usd: reading.usd, resetsAt: reading.resetsAt, carriedRate: carried }
38}
39
40export const rateOf = (track: LimitTrack | null): number | null => (track ? rateBetween(track) ?? track.carriedRate ?? null : null)
41
42export const shareOf = (costUsd: number, track: LimitTrack | null): number | null => {
43 const rate = rateOf(track)
44 return rate === null ? null : costUsd * rate
45}
46
47// "<0,1", "3,4" or "12" (the decimal mark follows the language): a tenth of a point is the finest the window reports.
48export const shareNumber = (points: number): string => {
49 if (points < TENTH) return `<${fixed(TENTH, 1)}`
50 return points < ONE_DECIMAL_BELOW ? fixed(points, 1) : String(Math.round(points))
51}
52hooks/pane.tsx 234 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Draft, Panel } from '../types'
5import { BUILTIN_TYPES, DEFAULT_TYPE, TASK_TITLE_MAX, blankDraft, chosenOf } from './agent-types'
6import { addRun, askedAgain } from './agents'
7import { quietTree, waitingTree } from './chats-tree'
8import { draftTree } from './dispatch'
9import { HomeButton } from './home'
10import type { DraftView } from './dispatch'
11import { clip } from './format'
12import { t } from './i18n'
13import { lookOf } from './identity'
14import { restingCrab } from './mood'
15import { paneDataOf } from './pane-data'
16import type { PaneData } from './pane-data'
17import { FRAME_COLS, FRAME_PX, agentRow, plannedRow } from './rows'
18import type { RowContext, Ui } from './rows'
19import { attentionTree, compactTree, emptyTree, headTree, sectionHead, timelineTree, toolbarTree } from './sections'
20import type { Layout, PaneHandlers, PaneView } from './sections'
21import { pinned } from './pin'
22import { PANE } from './state'
23import { deliveryText } from './steer'
24import type { StopNoun } from './steer'
25
26// The Ajanlar panel: the header card, the toolbar ("Yeni görev", Daralt), the attention line, the
27// form while it is open, the timeline, then the running, finished and planned agents as cards.
28
29// State refs live in each file that reads them: the scan follows literals, not imports.
30const agents = atom({ plugin: 'savvy-progress', key: 'agents' } as const, [])
31const flow = atom({ plugin: 'savvy-progress', key: 'flow' } as const, null)
32const job = atom({ plugin: 'savvy-progress', key: 'job' } as const, null)
33const now = atom({ plugin: 'savvy-progress', key: 'now' } as const, 0)
34const panel = atom({ plugin: 'savvy-progress', key: 'panel' } as const, { isCompact: false, isDoneCollapsed: false })
35const tasks = atom({ plugin: 'savvy-progress', key: 'tasks' } as const, [])
36const limit = atom({ plugin: 'savvy-progress', key: 'limit' } as const, null)
37const types = atom({ plugin: 'savvy-progress', key: 'types' } as const, BUILTIN_TYPES)
38const crab = atom({ plugin: 'savvy-progress', key: 'crab' } as const, restingCrab(0))
39const chats = atom({ plugin: 'savvy-progress', key: 'chats' } as const, [])
40
41type On = Parameters<Register>[0]
42
43const MIN_W = 240
44const MAX_W = 900
45// MODZ measures the desktop in 8 px cells (kit `PX_PER_CELL`).
46const PX_PER_COL = 8
47const MIN_COLS = 24
48const DEFAULT_COLS = 40
49
50// Asking an agent more from its card (a finished one picks up again), and stopping it through the
51// engine's own TaskStop tool (the normal permission chain applies). A delivered question joins the
52// card's conversation; only a refusal needs a toast, since the card itself shows the question.
53async function sendTo($: EngineInterface, id: string, text: string): Promise<void> {
54 const message = text.trim()
55 if (!message) return
56 const run = (await read($, agents)).find(a => a.id === id)
57 const sent = await $.session.send({ to: { agentId: run?.agentId ?? id }, text: message })
58 if (!sent.isDelivered) {
59 $.ui.toast(deliveryText(sent))
60 return
61 }
62 const at = await $.clock.now()
63 await update($, agents, list => list.map(a => (a.id === id ? askedAgain(a, message, at) : a)))
64}
65
66async function requestStop($: StopNoun, agentId: string): Promise<boolean> {
67 const ran = await $.tool.call({ tool: 'TaskStop', task_id: agentId })
68 return ran.deny === undefined
69}
70
71async function stopAgent($: EngineInterface, agentId: string): Promise<void> {
72 try {
73 const isStopped = await requestStop($ as unknown as StopNoun, agentId)
74 $.ui.toast(isStopped ? t().stopAsked : t().stopRefused)
75 } catch (error) {
76 $.ui.log(`savvy-progress: stop failed (${String(error)})`, { to: 'debug' })
77 $.ui.toast(t().stopFailed)
78 }
79}
80
81// Every value the panel draws from; each read subscribes the pane to it.
82async function paneData($: EngineInterface): Promise<PaneData> {
83 return paneDataOf({
84 list: await read($, agents),
85 flow: await read($, flow),
86 job: await read($, job),
87 tasks: await read($, tasks),
88 now: await read($, now),
89 panel: await read($, panel),
90 limit: await read($, limit),
91 types: await read($, types),
92 crab: await read($, crab),
93 chats: await read($, chats),
94 })
95}
96
97const draftOf = (draft: Draft | undefined): Draft => draft ?? blankDraft()
98
99// Changes the "Yeni görev" form and leaves the rest of the panel as it was.
100const patchDraft = ($: EngineInterface, patch: Partial<Draft>) =>
101 update($, panel, prev => ({ ...prev, draft: { ...draftOf(prev.draft), ...patch } }))
102
103// Starts the task; a refusal stays on the form, a start closes it and opens the new agent's card.
104async function startTask($: EngineInterface, type: string, text: string): Promise<void> {
105 const prompt = text.trim()
106 if (!prompt) return
107 try {
108 const description = clip(prompt.split('\n')[0] ?? prompt, TASK_TITLE_MAX)
109 const started = await $.agent.spawn({ subagentType: type, prompt, description })
110 if (started.deny !== undefined) {
111 await patchDraft($, { notice: t().denied(started.deny) })
112 return
113 }
114 const id = started.agentId
115 // Our own spawn hook does not see a spawn this plugin made: the card is added here, at once.
116 const at = await $.clock.now()
117 const spawn = { id: id ?? `task-${at}`, agentId: id, type, description, brief: prompt, model: started.model, isAsked: true, at }
118 const isKnown = (a: { agentId?: string }) => a.agentId !== undefined && a.agentId === id
119 const list = await update($, agents, prev => (prev.some(isKnown) ? prev.map(a => (isKnown(a) ? { ...a, isAsked: true } : a)) : addRun(prev, spawn)))
120 await update($, panel, prev => ({ ...prev, selected: spawn.id, draft: { ...draftOf(prev.draft), isOpen: false, notice: undefined } }))
121 const run = list.find(a => a.id === spawn.id)
122 $.ui.toast(t().launched(run ? lookOf(run).name : type))
123 } catch (error) {
124 $.ui.log(`savvy-progress: spawn failed (${String(error)})`, { to: 'debug' })
125 await patchDraft($, { notice: t().spawnFailed })
126 }
127}
128
129const draftHandlers = ($: EngineInterface, view: Omit<DraftView, 'layout'>): Pick<PaneHandlers, 'onNewTask' | 'onCancelTask' | 'onPickType' | 'onStartTask'> => ({
130 onNewTask: () => void patchDraft($, { isOpen: !draftOf(view.draft).isOpen, notice: undefined }),
131 onCancelTask: () => void patchDraft($, { isOpen: false, notice: undefined }),
132 onPickType: name => void patchDraft($, { type: name, notice: undefined }),
133 onStartTask: text => void startTask($, chosenOf(view.types, view.draft)?.name ?? DEFAULT_TYPE, text),
134})
135
136function handlersOf($: EngineInterface, d: PaneData): PaneHandlers {
137 const flip = (fn: (prev: Panel) => Panel) => () => void update($, panel, fn)
138 return {
139 onToggle: id => void update($, panel, prev => ({ ...prev, selected: prev.selected === id ? undefined : id })),
140 onLook: id => void update($, panel, prev => ({ ...prev, selected: id, isCompact: false })),
141 onBrief: id => void update($, panel, prev => ({ ...prev, briefOpen: prev.briefOpen === id ? undefined : id })),
142 onSend: (id, text) => void sendTo($, id, text),
143 onStop: id => void stopAgent($, id),
144 onCompact: flip(prev => ({ ...prev, isCompact: !prev.isCompact })),
145 onDone: flip(prev => ({ ...prev, isDoneCollapsed: !prev.isDoneCollapsed })),
146 onTimeline: flip(prev => ({ ...prev, isTimelineHidden: prev.isTimelineHidden !== true })),
147 onChats: flip(prev => ({ ...prev, isChatsOpen: prev.isChatsOpen !== true })),
148 ...draftHandlers($, { draft: d.panel.draft, types: d.types }),
149 }
150}
151
152function listTree(ui: Ui, view: PaneView) {
153 const { Box } = ui
154 const { d, layout, on } = view
155 const s = t()
156 const ctx: RowContext = { ...on, at: d.at, isDesktop: layout.isDesktop, inner: layout.inner, limit: d.limit, selected: d.panel.selected, briefOpen: d.panel.briefOpen }
157 const isDoneOpen = !d.panel.isDoneCollapsed
158 return (
159 <Box key="list" flexDirection="column" rowGap={1}>
160 {d.running.length > 0 && sectionHead(ui, layout, { id: 'running', label: `${s.running} · ${d.running.length}`, dot: 'run' })}
161 {d.running.map(a => agentRow(ui, a, ctx))}
162 {d.finished.length > 0 && sectionHead(ui, layout, { id: 'finished', label: `${s.finished} · ${d.finished.length}`, dot: 'ok', toggle: { key: 'done', isOpen: isDoneOpen, onPress: on.onDone } })}
163 {isDoneOpen && d.finished.map(a => agentRow(ui, a, ctx))}
164 {d.planned.length > 0 && sectionHead(ui, layout, { id: 'planned', label: `${s.planned} · ${d.planned.length}`, dot: 'idle' })}
165 {d.planned.map(p => plannedRow(ui, p, ctx))}
166 </Box>
167 )
168}
169
170// Who else on this machine waits on the person comes first; the quiet rest folds at the foot.
171function withChats(ui: Ui, view: PaneView) {
172 const { Box } = ui
173 const { d, layout, on } = view
174 const quiet = quietTree(ui, { chats: d.chats, layout, isOpen: d.panel.isChatsOpen === true, onToggle: on.onChats })
175 return (
176 <Box flexDirection="column" rowGap={1}>
177 {waitingTree(ui, d.chats, layout)}
178 {paneTree(ui, view)}
179 {quiet}
180 </Box>
181 )
182}
183
184function paneTree(ui: Ui, view: PaneView) {
185 const { Box } = ui
186 const { d, layout, on } = view
187 const isDrafting = d.panel.draft?.isOpen === true
188 const form = isDrafting && draftTree(ui, { draft: d.panel.draft, types: d.types, layout }, on)
189 if (d.list.length === 0 && d.planned.length === 0) {
190 return <Box flexDirection="column" rowGap={2}>{isDrafting ? form : emptyTree(ui, view)}</Box>
191 }
192 return (
193 <Box flexDirection="column" rowGap={1}>
194 {headTree(ui, d, layout)}
195 {toolbarTree(ui, d, on)}
196 {attentionTree(ui, view)}
197 {form}
198 {d.panel.isCompact ? compactTree(ui, d, layout) : timelineTree(ui, view)}
199 {!d.panel.isCompact && listTree(ui, view)}
200 </Box>
201 )
202}
203
204// Asks the hub to come forward; the hub hears this mod's own `home` value. A failure only shows in the debug log.
205async function goHome($: EngineInterface): Promise<void> {
206 try {
207 await $.state.set({ plugin: 'savvy-progress', key: 'home' }, { at: await $.clock.now() })
208 } catch (error) {
209 $.ui.log(`savvy-progress: home failed (${String(error)})`, { to: 'debug' })
210 }
211}
212
213/** The pane's room: the desktop in px (a card's inside less its frame), the terminal in cells. */
214export const layoutOf = (isDesktop: boolean, columns: number): Layout => {
215 if (!isDesktop) return { isDesktop, W: 0, inner: columns, cols: columns }
216 const W = Math.max(MIN_W, Math.min(MAX_W, columns * PX_PER_COL))
217 return { isDesktop, W, inner: W - FRAME_PX, cols: columns - FRAME_COLS }
218}
219
220export function onPane(on: On): void {
221 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
222 const table = $.ui.resolve(e)
223 const ui = table as Ui
224 const isDesktop = e.surface === 'desktop' && 'Svg' in ui
225 const d = await paneData($)
226 const layout = layoutOf(isDesktop, Math.max(MIN_COLS, e.props.bodyColumns || DEFAULT_COLS))
227 const isHubOn = (await $.state.get({ plugin: 'modz', key: 'isOn' })).value === true
228 const body = withChats(ui, { d, layout, on: handlersOf($, d) })
229 if (!isHubOn) return pinned(e, body)
230 const { Box } = ui
231 return pinned(e, <Box flexDirection="column" rowGap={1}>{HomeButton(ui, () => void goHome($))}{body}</Box>)
232 })
233}
234hooks/presence.ts 227 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { HomeCrab, SelfChat } from '../types'
5import { hubCardOf } from './hub'
6import { HEARTBEAT_MS, beatOf, blankSelf, bodyOf, keyOf, projectOf, scanOf, settle, titleOf, withAsk, withoutAsk } from './chats'
7import { TURNS_KEY, ended, rested, restingCrab, started, turnsFrom } from './mood'
8import type { TurnEnd } from './mood'
9import { isSame } from './same'
10import { HUB_PANE } from './state'
11
12// The session as the other chats on this machine and the home crab see it, the side where `$` is.
13// This session's heartbeat goes to the store when what it says changes and on each slow beat, so
14// the others know it is alive; their heartbeats come back into `chats` for the pane and the hub.
15// Waiting on the person is what done-ping calls it: a permission ask (`tool.check` answered
16// `ask`) or an open question. The main loop's turns move the crab; each answer adds to the count
17// every session shares. A module hooks an event once without a matcher, and register.tsx and
18// events.ts hold those for session.start, turn.complete and tool.call: here each has a matcher.
19
20// State refs live in each file that reads them: the scan follows literals, not imports.
21const self = atom({ plugin: 'savvy-progress', key: 'self' } as const, null)
22const chats = atom({ plugin: 'savvy-progress', key: 'chats' } as const, [])
23const crab = atom({ plugin: 'savvy-progress', key: 'crab' } as const, restingCrab(0))
24const agents = atom({ plugin: 'savvy-progress', key: 'agents' } as const, [])
25const flow = atom({ plugin: 'savvy-progress', key: 'flow' } as const, null)
26const job = atom({ plugin: 'savvy-progress', key: 'job' } as const, null)
27const tasks = atom({ plugin: 'savvy-progress', key: 'tasks' } as const, [])
28const hub = atom({ plugin: 'savvy-progress', key: 'hub' } as const, { pane: HUB_PANE, detail: '', tone: 'idle', at: 0 })
29
30type On = Parameters<Register>[0]
31
32const QUESTION_TOOL = 'AskUserQuestion'
33// Every way a turn ends and every session start: a matcher that admits them all (see above).
34const ANY_END: TurnEnd[] = ['answer', 'aborted', 'refusal', 'error']
35const ANY_SESSION = [true, false]
36const ANY_TOOL = /./
37
38const log = ($: EngineInterface, what: string, error: unknown): void =>
39 $.ui.log(`savvy-progress: ${what} (${String(error)})`, { to: 'debug' })
40
41// One change to this session's side, written only when it moved; `since` follows a change of state.
42async function change($: EngineInterface, fn: (prev: SelfChat) => SelfChat): Promise<boolean> {
43 const prev = await read($, self)
44 if (!prev) return false
45 const at = await $.clock.now()
46 if (isSame(settle(prev, fn(prev), at), prev)) return false
47 await update($, self, current => (current ? settle(current, fn(current), at) : current))
48 return true
49}
50
51// The heartbeat goes out when what it says changed, or always on a beat. A new session id
52// (after /clear) takes over from the old key; an ended one is never written again.
53async function publish($: EngineInterface, isBeat: boolean): Promise<void> {
54 const mine = await read($, self)
55 if (!mine) return
56 const id = await $.session.id()
57 if (id === mine.endedId) return
58 const beat = beatOf(mine, await $.clock.now())
59 if (!isBeat && id === mine.id && isSame(bodyOf(beat), mine.sent)) return
60 if (mine.id && mine.id !== id) await $.store.delete(keyOf(mine.id))
61 await $.store.set(keyOf(id), beat)
62 await update($, self, current => (current ? { ...current, id, sent: bodyOf(beat) } : current))
63}
64
65async function changeAndPublish($: EngineInterface, fn: (prev: SelfChat) => SelfChat): Promise<void> {
66 try {
67 if (await change($, fn)) await publish($, false)
68 } catch (error) {
69 log($, 'heartbeat not written', error)
70 }
71}
72
73// The others' heartbeats, read back; the gone and the unreadable are pruned for everyone.
74async function scan($: EngineInterface): Promise<void> {
75 const own = keyOf(await $.session.id())
76 const entries: [string, unknown][] = []
77 for (const key of await $.store.keys()) if (key !== own) entries.push([key, await $.store.get(key)])
78 const found = scanOf(entries, own, await $.clock.now())
79 for (const key of found.gone) await $.store.delete(key)
80 const prev = await read($, chats)
81 if (isSame(found.chats, prev)) return
82 await update($, chats, () => found.chats)
83 await syncHub($)
84}
85
86// The hub card says when another chat waits. Written here, not from a state.set hook: a timer's
87// writes come back under this plugin's own frame, where its own hooks are not run again.
88async function syncHub($: EngineInterface): Promise<void> {
89 const sources = { flow: await read($, flow), agents: await read($, agents), job: await read($, job), tasks: await read($, tasks) }
90 const { detail, tone } = hubCardOf({ ...sources, chats: await read($, chats) })
91 const prev = await read($, hub)
92 if (prev.detail === detail && prev.tone === tone) return
93 const at = await $.clock.now()
94 await update($, hub, () => ({ pane: HUB_PANE, detail, tone, at }))
95}
96
97async function moveCrab($: EngineInterface, fn: (prev: HomeCrab) => HomeCrab): Promise<void> {
98 const prev = await read($, crab)
99 if (!isSame(fn(prev), prev)) await update($, crab, fn)
100}
101
102// One slow beat: say this session is alive, read who else is, let a rested crab nap.
103async function beat($: EngineInterface): Promise<void> {
104 try {
105 await publish($, true)
106 await scan($)
107 const now = await $.clock.now()
108 await moveCrab($, prev => rested(prev, now))
109 } catch (error) {
110 log($, 'no chat heartbeat this beat', error)
111 }
112}
113
114async function projectName($: EngineInterface, cwd: string): Promise<string> {
115 try {
116 const repo = await $.session.repo()
117 return projectOf(repo?.root ?? cwd)
118 } catch (error) {
119 log($, 'no repository, naming the folder', error)
120 return projectOf(cwd)
121 }
122}
123
124// A reload keeps what the session and the crab were doing; a fresh session starts idle and asleep.
125async function start($: EngineInterface, cwd: string): Promise<void> {
126 try {
127 const turns = turnsFrom(await $.store.get(TURNS_KEY))
128 await moveCrab($, prev => (prev.mood === 'working' ? { ...prev, turns } : { ...prev, mood: 'sleeping', turns }))
129 const at = await $.clock.now()
130 const project = await projectName($, cwd)
131 const prev = await read($, self)
132 await update($, self, () => (prev ? { ...prev, project, endedId: undefined } : { ...blankSelf(at), project }))
133 } catch (error) {
134 log($, 'other chats and the crab wait for the next beat', error)
135 }
136 await beat($)
137 $.clock.every(HEARTBEAT_MS, () => void beat($))
138}
139
140// The store's count first, so two sessions answering one after another both count.
141async function countTurn($: EngineInterface): Promise<number> {
142 const turns = turnsFrom(await $.store.get(TURNS_KEY)) + 1
143 await $.store.set(TURNS_KEY, turns)
144 return turns
145}
146
147async function crabEnded($: EngineInterface, reason: TurnEnd): Promise<void> {
148 try {
149 const at = await $.clock.now()
150 const turns = reason === 'answer' ? await countTurn($) : (await read($, crab)).turns
151 await moveCrab($, prev => ({ ...ended(prev, { reason, at }), turns }))
152 } catch (error) {
153 log($, 'the crab missed a turn', error)
154 }
155}
156
157// A tool call resolved (run or refused): a permission ask or a question it opened waits no more.
158async function askClosed($: EngineInterface, id: unknown): Promise<void> {
159 if (typeof id !== 'string' || !(await read($, self))?.asks.includes(id)) return
160 await changeAndPublish($, prev => withoutAsk(prev, id))
161}
162
163function onSession(on: On): void {
164 on('session.start', { isInteractive: ANY_SESSION }, async ($, e, next) => {
165 const result = await next(e)
166 await start($, result.cwd)
167 return result
168 }).catch(($, e, next) => next(e))
169
170 // The session's key goes with it; after a /clear the next write takes the new id.
171 on('session.end', async ($, e, next) => {
172 try {
173 await $.store.delete(keyOf(e.sessionId))
174 const at = await $.clock.now()
175 await update($, self, prev => ({ ...blankSelf(at), project: prev?.project ?? '', endedId: e.sessionId }))
176 } catch (error) {
177 log($, 'heartbeat not removed', error)
178 }
179 return next(e)
180 }).catch(($, e, next) => next(e))
181}
182
183// Only the main loop moves the chat and the crab; a subagent's run is its own crab's business.
184function onTurns(on: On): void {
185 on('turn.start', async ($, e, next) => {
186 const result = await next(e)
187 await changeAndPublish($, prev => ({ ...prev, isWorking: true, title: prev.title || titleOf(e.text) }))
188 await moveCrab($, started).catch(error => log($, 'the crab missed a turn', error))
189 return result
190 }).catch(($, e, next) => next(e))
191
192 // Nothing waits once the turn is over: a dialog does not outlive its turn.
193 on('turn.complete', { reason: ANY_END }, async ($, e, next) => {
194 const result = await next(e)
195 if (e.agentId !== undefined) return result
196 await changeAndPublish($, prev => ({ ...prev, isWorking: false, asks: [] }))
197 await crabEnded($, e.reason)
198 return result
199 }).catch(($, e, next) => next(e))
200}
201
202// A permission ask waits from its `ask` verdict until its call resolves; a question while it is open.
203function onAsks(on: On): void {
204 on('tool.check', async ($, e, next) => {
205 const verdict = await next(e)
206 const id = e.tool_use_id
207 if (verdict.decision === 'ask' && id !== undefined) await changeAndPublish($, prev => withAsk(prev, id))
208 return verdict
209 }).catch(($, e, next) => next(e))
210
211 on('tool.call', { tool: ANY_TOOL }, async ($, e, next) => {
212 const id = (e as { tool_use_id?: unknown }).tool_use_id
213 if (typeof id === 'string' && e.tool === QUESTION_TOOL) await changeAndPublish($, prev => withAsk(prev, id))
214 try {
215 return await next(e)
216 } finally {
217 await askClosed($, id)
218 }
219 }).catch(($, e, next) => next(e))
220}
221
222export function onPresence(on: On): void {
223 onSession(on)
224 onTurns(on)
225 onAsks(on)
226}
227