SLOPSHOPPER

savvy-progress

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…

newpanebandrowsguardcommand
★ 2v?MITupdated 2026-10-16partalemre/MODZ/mods/savvy-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · savvy-progress
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ (^_^) · Lv 1 │ ┃ ● savvy-progress: savvy-progress: heartbeat not written (TypeError: u │ ┃ ● NO AGENTS YET ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Hand out a task and a crab gets to work: its ⏺ Update(src/auth.ts) │ ┃ status, progress and cost show here live. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ [ + New task ] ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /agents-info │ ⎿ savvy-progress: Agents panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
(^_^) · Lv 1 ● NO AGENTS YET Hand out a task and a crab gets to work: its status, progress and cost show here live. [ + New task ]
README

savvy-progress

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.

Install

/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.

What you see

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):

  • Header card: the job name and percentage, a segmented meter, counts (2 running · 1 done · 1 planned) and Cost, Tokens, Time and the share of the 5-hour limit.
  • Attention row: the one agent that most needs a look. Amber when a running agent has been silent for 2 minutes, or one tool has run for 15 minutes; red when an agent failed on its own in the last 10 minutes. An agent you stopped does not count.
  • Timeline (with two or more agents, 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.
  • Cards (Running, Finished, Planned): the crab, a name and tier (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).
  • Opened card (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.
  • New task: pick an agent type, describe the work, press Enter to start it. Types are the built-ins (general-purpose, Explore, Plan), every type the engine offers the model (from plugins and settings), and files in .claude/agents/ and ~/.claude/agents/.
  • Crabs: each agent gets its own shell colour, eyes and name in arrival order, with a costume for its savvy-flow tier. Only running agents move; reduced-motion settings stop all of it.
  • Home crab: the pane's own crab stands in the header (and in the empty pane). It sleeps while nothing runs, works while a turn runs (it walks only when no agent's crab does), looks happy after a turn that answered and worried after one that errored or was stopped, then naps again after 10 quiet minutes. Its level pill (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, (•_•), (^_^), (°_°;).
  • Other chats: when another Claude Code session on this machine waits on you (a permission dialog or a question), a "Waiting on you" card at the very top names it: 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.

How the other chats are found

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.

Commands

  • /agents-info opens or closes the Agents pane. The Agents row in the MODZ panel only opens it.

Tools it adds

  • 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).

Settings

OptionDefaultMeaning
languageautotr, en or ru. auto follows MODZ_LANG, then Claude Code's language setting, then the system locale, else English.
crabInBandfalseShows 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" } } } }

Limits

  • Cost is a rough estimate from token counts and a built-in price table, not a bill.
  • The percentage of an agent that reports nothing is an estimate and is marked ~.
  • A permission ask counts as waiting from the dialog until its tool call finishes, so a long command you approved still reads waiting until it ends (the engine raises no event for the answer itself). A turn's end always clears it.
  • Other chats show up only in sessions that run this mod. A session that is suspended (a sleeping laptop) drops off after 2 minutes and comes back on its next beat.
  • The turn count behind the level is read, added to and written back by each session; two sessions finishing in the same instant may count once.

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.

Language

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.

Credits

Adapted from johnnyvizz/claude-kit (plugins/savvy-progress), licensed MIT. The license file is in this folder.

Source 64 files
hooks/register.tsx 234 lines
1import { 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}
234
hooks/agent-types.ts 54 lines
1import 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})
54
hooks/agents.ts 147 lines
1import 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}
147
hooks/band.tsx 222 lines
1import { 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}
222
hooks/dispatch.tsx 59 lines
1import { 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}
59
hooks/events.ts 170 lines
1import { 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}
170
hooks/flow.ts 61 lines
1import 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}
61
hooks/hub.ts 51 lines
1import 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}
51
hooks/i18n.ts 35 lines
1// 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
35
hooks/limit.ts 52 lines
1import 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}
52
hooks/pane.tsx 234 lines
1import { 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}
234
hooks/presence.ts 227 lines
1import { 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