A route for /goal and any multi-step work, built from Claude's own task plan: the plan drawn as a line of nodes and legs above the prompt, an ETA at the goal's…

A progress bar for what Claude is working through: the task plan as a bar, with elapsed time and an ETA.
Waypoints is a fork of Nate Herk's goal-meter 1.0.0 (MIT, see LICENSE), imported with its history, so the original commit keeps its author. Everything that makes the meter work is Nate's: the task tool Claude plans with, the band and footer, the ETA at the goal's own pace, the goal check's "not met yet" reason, and the pane with every chat's goal. This fork renames it and builds the plan request into the mod, so nothing has to be added to your global CLAUDE.md.
Claude plans the work as tasks sized S, M or L with the mod's own tool, mcp__waypoints__tasks, then marks each one started and done. Everything on screen comes from that list.
✦ at the start of its leg, a parked task's leg stays dashed with a ⏸, and the last node is a flag: ⚐ on the way, ⚑ once the plan is done. A long plan in a narrow window drops the legs and draws one glyph per task. Under it: what is running and who runs it, the next task, and one notable row: the goal check's latest "not met yet" reason, paused, waiting on you, or ⏸ parked: <blocker>. In the header: tasks done, the percentage, the ETA at this goal's own pace and how many tasks are parked.◎ goal 69% · ~15m, whether or not the band shows. On Claude Desktop, where the band already draws the plan, it appears in the footer only while the band is hidden with /waypoints hide./waypoints opens this chat's whole task trail (every task, how long each took, who is running which, what a finished task turned out to be, and why the plan was revised) and your other chats' goals from the last hour (the newest 15; the heading counts them all), each with its own small route. It also opens by itself when a goal's first plan starts, without taking the keys from your prompt, unless you closed the pane during that goal.◎ Goal: Add band to Settings type… 3 of 6 · 50% · ~2m all tasks g
●━━━━━━━━●━━━━●━━━━━━━━✦┄┄┄┄┄┄┄┄○┄┄┄┄○┄┄┄┄┄┄┄┄⚐ +2
⠋ M Tests: normalize, command, band on/off ◆ reviewer · 1m
next ○ S README, version bump, marketplace.json
The ETA appears after two tasks are done. When a goal finishes the full band stays for 8 seconds, then collapses to one row; three minutes after the finish the band goes, and a pane that Waypoints opened closes with it, within about 15 seconds (a pane you opened yourself stays). The mod makes no model calls of its own.
In /config, under Waypoints:
| Setting | Values | Default |
|---|---|---|
| Palette | follow, ember, claude, neon, mono | follow |
| Animation | follow, off, subtle, full | follow |
| Pane | auto, off | auto |
follow takes Agent-Pulse's own palette or animation, so the two bands never disagree, and falls back to ember / subtle when Agent-Pulse is not installed. Pick a value to stop following. Colour and shape show at every animation level; off keeps everything still, subtle pulses the running task, full also breathes the node and sweeps the route when the plan finishes. Pane: off opens the pane only when you ask (/waypoints or g). A window narrower than 144 columns leaves an unasked pane waiting until it widens; g and /waypoints open it at any width.
Sometimes everything this chat can do is done and the last task waits on someone else: another team's reply, a release window, a person who is away. Claude parks that task with a note naming the blocker. A parked task stays on the route as a dashed leg with a ⏸, but leaves the header's numbers, so the plan reads 6 of 6 · 100% · 1 parked and finishes green; the summary row keeps 1 parked: <blocker>, so green is not read as "all of it is done". paused, waiting on you now shows only when an open task really waits on you.
Claude is also asked two things in the same words that ask it to plan: when a task turns out differently than the plan assumed, fix the later tasks and say what changed (the pane shows revised after #3: <why>); and never end a turn on an open task without saying why. It finishes the task, parks it, or, when it is unsure what the task takes, asks you with options and a recommendation.
| Work | What asks for a plan |
|---|---|
A /goal | The mod adds a note to the command asking for a plan. If four tool calls pass with no plan, it reminds Claude once. Until there is a plan, Claude's Edit, Write, NotebookEdit and MultiEdit calls are refused, so the bar is full from the start (Bash and subagents are not held back, and nothing is refused if the mod's tool did not register). |
| Any other work of 3 or more steps | A standing line in the system prompt (new in 1.1.0). A plan made this way shows the same, named after the one-line goal Claude gives it (its first task if it gives none) and labelled Plan where a goal is labelled Goal. |
| A small, single-step request | Nothing: the line tells Claude to skip it. |
The standing line tells subagents never to call plan, because it replaces the main agent's pending tasks; they can start and done tasks. It is a request, not a rule: the tool does not refuse a subagent's plan. The line is also left out of bare, print and teammate sessions, and while /waypoints auto is off.
The standing line is the one thing the global CLAUDE.md bullet used to do. If you still have that bullet from goal-meter, delete it.
Needs function hooks, which are early access. Where an organization manages Claude Code, the engine passes over
prompt.composein plugins you install, so the standing line does not run there; a/goalstill asks for a plan.
| Command | What it does |
|---|---|
/waypoints | Opens the pane (so does any word it does not know). Where the surface draws none, it prints the plan as text. |
/waypoints hide, show | Hides or shows the band, and says so in a toast. The footer stays. A new goal or plan shows it again. |
/waypoints auto on, off | Turns the standing line on or off. On by default; the choice is kept between sessions. The pane's last row shows which it is. |
/waypoints clear | Marks this chat's running goal as stopped; the band keeps showing stopped for three minutes. A finished goal is left as it is. |
/waypoints prune | Deletes old files in ~/.claude/mods-data/waypoints/: other chats' goals that finished over an hour ago, and files untouched for a day. Never this chat's file, never a running goal's. It deletes them straight away, with no dialog: typing the command is the confirmation. Past 100 files the pane names the folder and reminds you. |
For auto, anything but off turns it on. /goal clear (or stop, off, cancel, end, none, reset) stops the goal in the same way. If Claude Code will not give the mod the name /waypoints, it registers /waypoints-mod instead.
In the band, the all tasks button (or g) opens the pane. g is a button in the band, so it only works while the band shows; /waypoints always does.
park action, with a required note naming the blocker, takes a task that waits on someone outside the chat out of the numbers, so the plan can finish green. The band, the summary row and the pane name it. See "When the last step is not Claude's to finish".plan or add takes a note saying what changed. The pane shows revised after #n, a finished task's note and the new tasks marked revised.⚐, then ⚑ when done) instead of a ◎ among the circles, and the running task is a twinkling ✦, in the pane too.The options are fewer and say what they do. /waypoints hide and show confirm in a toast. The pane's last row shows whether auto is on, what clear does and that the look and the pane's opening are set in /config → Waypoints. Other chats are listed for an hour instead of twelve. /waypoints prune deletes old data files, and the pane reminds you once the folder holds more than 100.
A /goal now always plans before Claude edits files; the strict option is gone.
Fixed: on Claude Desktop the footer's ◎ plan 44% never showed, so /waypoints hide left no sign of a running plan. Desktop does not draw the labels a mod adds to the footer, only what it draws as a tree, so there the label is now a tree around the engine's footer. It shows only while the band is hidden, because the band already shows the plan; the terminal is unchanged.
Fixed: a plan made without /goal was named after its first task, so a plan to review the README showed as "Plan: Review README intro". The plan action now takes an optional goal, one line naming what the whole plan achieves, and the standing line asks Claude for it. The title, the toast and the pane use it; without one the plan is still named after its first task.
Fixed: on the terminal the route row was missing and the running node did not pulse, because the band took the terminal for Desktop. It now goes by the surface the engine names.
Only how it looks and when the pane opens; what Claude is told and the tool it plans with are the same as in 1.1, and the data files are unchanged, so chats still on 1.1 appear in your pane.
+n more.Pane setting) and closes with the band.cyan, green), which a skin can remap, so the colours no longer depend on your theme unless you pick claude or mono.| goal-meter 1.0.0 | Waypoints | |
|---|---|---|
| Plugin | goal-meter | waypoints |
| Tool | mcp__goal-meter__tasks | mcp__waypoints__tasks |
| Command | /goals | /waypoints |
| State folder | ~/.claude/mods-data/goal-meter/ | ~/.claude/mods-data/waypoints/ |
Plans outside /goal | Only with a line in the global CLAUDE.md | A standing line in the system prompt, /waypoints auto off to remove it |
Switching: uninstall goal-meter@nateherk-mods, install waypoints@afterever-mods, delete the goal-meter bullet from the global CLAUDE.md, and open a new chat. Goals saved by goal-meter are not carried over; they only last a day anyway.
State is one JSON file per chat in ~/.claude/mods-data/waypoints/, written so the pane can list other chats; /waypoints prune deletes the old ones. When Recording Mode's flag is on, titles and notes are masked before they are drawn.
hooks/register.mjs 675 lines1// Waypoints: a progress bar for /goal, built from Claude's own task plan.
2// (A fork of Nate Herk's goal-meter 1.0.0.)
3// - /goal starts it. The mod asks Claude to plan the goal as sized tasks (S, M, L)
4// with the mod's tool, mcp__waypoints__tasks, then mark each task started and
5// done. Claude Code stopped shipping a task-list tool after 2.1.229, so the mod
6// brings its own. A plan made outside a /goal shows the same way.
7// - The band above the prompt: the route (the plan as nodes and legs, weighted by
8// size), the ETA at this goal's own pace, what's running and who runs it, the next
9// task, and the goal check's latest "not met yet" reason. The drawing is in draw.mjs.
10// - The footer: "◎ goal 69% · ~15m" in every view, terminal and Desktop.
11// - /waypoints: a pane with this chat's whole task trail and every other chat's goal.
12// It opens by itself when a plan starts (userConfig pane: auto|off).
13// /waypoints hide|show (the band), /waypoints auto on|off, /waypoints clear.
14// - In a /goal, file edits wait until Claude has planned.
15// - The look (userConfig palette, animation) follows Agent-Pulse's by default.
16// - A standing line in the system prompt asks Claude to plan any multi-step work,
17// not only a /goal; /waypoints auto off removes it.
18// - Each chat writes ~/.claude/mods-data/waypoints/<session>.json for /waypoints.
19// The mod sends no model requests of its own.
20
21import { minutes, clip, bar, basename } from './fmt.mjs'
22import { makeMasker } from './privacy.mjs'
23import { newGoal, applyAction, progress, eta, parseCheck, isStopWord, normalizeTasks, planSubject, TOOL_SPEC, instruction, standing, nudge, strictDeny } from './plan.mjs'
24import { rolesOf, resolveLook } from './palette.mjs'
25import { drawBand, drawPane, statsLine, label, FULL_MS } from './draw.mjs'
26import { createAutoPane } from './pane.mjs'
27import { OTHERS_MS, shownPath, pickPrunable, deleteArgv } from './prune.mjs'
28
29const DIR = '/.claude/mods-data/waypoints'
30const PANE = 'waypoints'
31const RECENT_MS = 3 * 60000 // a finished goal stays on screen this long
32const REOPEN_MS = 5 * 60000 // a goal closed on its tasks reopens if Claude carries on this soon
33const NUDGE_AFTER = 4 // tool calls into a goal with no plan before the reminder
34const WRITERS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
35
36let G = null
37let sessionId = ''
38let home = ''
39let win = false // the host is Windows: prune deletes with cmd's del
40let cwd = ''
41let now = 0
42let toolName = 'mcp__waypoints__tasks'
43let commandName = 'waypoints'
44let settings = { auto: true }
45let toolFailed = false
46let hidden = false
47let nudged = false
48let callsWithoutPlan = 0
49let pendingGoal = null
50let paneUp = false // the pane is open (placed or waiting), from $.ui.panes() and ui.close
51const autoPane = createAutoPane() // when the pane opens and closes by itself
52let options = { palette: 'follow', animation: 'follow', pane: 'auto' }
53let look = { palette: 'ember', level: 'subtle' }
54let roles = rolesOf('ember')
55let others = []
56let fileCount = 0 // the data folder's .json files, this chat's included
57let transcriptPath = ''
58const agentNames = new Map()
59const runningAgents = new Set()
60let rec = { on: false, strict: false }
61let mask = (s) => s
62
63function goalFile(id) {
64 return `${home}${DIR}/${id}.json`
65}
66
67function isRecent(g) {
68 return g && g.status !== 'running' && g.endedAt && now - g.endedAt < RECENT_MS
69}
70
71async function save($) {
72 if (!G || !sessionId) return
73 G.updatedAt = now
74 try {
75 await $.fs.write(goalFile(sessionId), JSON.stringify({ ...G, label: basename(cwd) }))
76 } catch {
77 // the pane falls back to this chat alone
78 }
79}
80
81async function restore($) {
82 try {
83 const path = goalFile(sessionId)
84 if (!(await $.fs.exists(path))) return
85 const saved = JSON.parse(await $.fs.read(path))
86 if (saved && Array.isArray(saved.tasks) && now - (saved.updatedAt || 0) < 24 * 3600000) G = saved
87 } catch {
88 G = null
89 }
90}
91
92async function loadOthers($) {
93 const dir = home + DIR
94 const list = []
95 let count = 0
96 try {
97 for (const entry of await $.fs.list(dir)) {
98 if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
99 count += 1
100 if (entry.name === sessionId + '.json') continue
101 if (now - (entry.mtimeMs || 0) > OTHERS_MS) continue
102 try {
103 const g = JSON.parse(await $.fs.read(dir + '/' + entry.name))
104 if (!g || !Array.isArray(g.tasks)) continue
105 if (g.status !== 'running' && now - (g.endedAt || 0) > OTHERS_MS) continue
106 list.push(g)
107 } catch {
108 // a file another chat is writing right now; next refresh reads it
109 }
110 }
111 } catch {
112 // no folder yet
113 }
114 fileCount = count
115 others = list.sort((a, b) => (a.status === 'running' ? 0 : 1) - (b.status === 'running' ? 0 : 1) || b.updatedAt - a.updatedAt)
116}
117
118// /waypoints prune: deletes what pickPrunable picks with the host's own delete ($.fs has
119// none), no shell and no dialog. Says what happened in words for a toast; the count is the
120// files really gone.
121async function prune($) {
122 const dir = home + DIR
123 const items = []
124 try {
125 for (const entry of await $.fs.list(dir)) {
126 if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
127 let goal = null
128 try {
129 goal = JSON.parse(await $.fs.read(dir + '/' + entry.name))
130 } catch {
131 // unreadable: pickPrunable keeps it while it is young
132 }
133 items.push({ name: entry.name, mtimeMs: entry.mtimeMs, goal })
134 }
135 } catch {
136 // no folder yet
137 }
138 const paths = pickPrunable(items, { now, self: sessionId + '.json' }).map((name) => dir + '/' + name)
139 if (!paths.length) return 'Nothing to prune'
140 const argv = deleteArgv(win, paths)
141 if (!argv) return `Prune did not run: delete old files by hand in ${shownPath(dir)}`
142 let r
143 try {
144 r = await $.process.run(argv)
145 } catch (err) {
146 return `Prune did not run: ${clip(String(err && err.message ? err.message : err), 80)}`
147 }
148 let gone = 0
149 for (const p of paths) if (!(await $.fs.exists(p))) gone += 1
150 await loadOthers($)
151 $.ui.invalidate('ui.render')
152 if (!gone) return `Prune did not run: ${clip(r.stderr || 'exit ' + r.exitCode, 80)}`
153 return `Pruned ${gone} old file${gone === 1 ? '' : 's'}`
154}
155
156async function readRecording($) {
157 try {
158 const path = (home || '.') + '/.claude/mods-data/recording.json'
159 if (!(await $.fs.exists(path))) rec = { on: false, strict: false }
160 else {
161 const flag = JSON.parse(await $.fs.read(path))
162 rec = { on: !!flag.on, strict: !!flag.on && !!flag.strict }
163 }
164 } catch {
165 rec = { on: false, strict: false }
166 }
167 mask = rec.on ? makeMasker({ strict: rec.strict }) : (s) => s
168}
169
170// The palette and animation to draw with: this mod's own setting, or 'follow' Agent-Pulse's row
171async function refreshLook($) {
172 let rows = []
173 try {
174 rows = await $.config.list()
175 } catch {
176 // no rows: follow falls back to ember / subtle
177 }
178 const next = resolveLook(options, rows)
179 if (next.palette === look.palette && next.level === look.level) return
180 look = next
181 roles = rolesOf(next.palette)
182 $.ui.invalidate('ui.render')
183}
184
185// Whether the pane is open: the engine's own record, which survives a reload
186async function refreshPanes($) {
187 try {
188 paneUp = (await $.ui.panes()).some((p) => p.id === PANE)
189 } catch {
190 // keep the last answer
191 }
192}
193
194// The first plan of a goal opens the pane, unasked: it never takes the keys from the
195// prompt, waits undrawn on a narrow window, and stays shut once the person closed it
196async function maybeAutoOpen($) {
197 await refreshPanes($)
198 if (!autoPane.canOpen(options.pane, G, paneUp)) return
199 try {
200 await loadOthers($)
201 // no `focus`: the pane never asks for the prompt's keys
202 await $.ui.open({ id: PANE, title: 'Waypoints', closeOnEscape: true })
203 paneUp = true
204 autoPane.opened()
205 } catch (err) {
206 // nobody asked, so no toast; the log keeps the reason
207 $.ui.log(`waypoints: the pane did not open (${err && err.message ? err.message : err})`)
208 }
209}
210
211// A pane the mod opened leaves with the band; one the person opened stays
212async function maybeAutoClose($) {
213 if (!autoPane.shouldClose(paneUp, !!G && (G.status === 'running' || isRecent(G)))) return
214 autoPane.takenOver()
215 try {
216 await $.ui.close({ id: PANE })
217 } catch {
218 // already gone
219 }
220 paneUp = false
221}
222
223async function registerCommand($) {
224 const spec = { name: 'waypoints', description: 'Waypoints: this chat\'s goal task list and every chat\'s goal (/waypoints hide|show|auto on|off|clear|prune)', argumentHint: '[hide|show|auto on|off|clear|prune]', immediate: true }
225 try {
226 await $.command.register(spec)
227 return 'waypoints'
228 } catch {
229 try {
230 await $.command.register({ ...spec, name: 'waypoints-mod' })
231 return 'waypoints-mod'
232 } catch {
233 return null
234 }
235 }
236}
237
238async function startGoal($, condition) {
239 G = newGoal({ sessionId, condition, now, cwd })
240 hidden = false
241 nudged = false
242 callsWithoutPlan = 0
243 pendingGoal = null
244 await save($)
245 $.ui.invalidate('ui.render')
246}
247
248async function stopGoal($, status) {
249 if (!G || G.status !== 'running') return
250 G.status = status
251 G.endedAt = now
252 G.active = false
253 await save($)
254 $.ui.invalidate('ui.render')
255}
256
257async function finishGoal($, how) {
258 if (!G || G.status !== 'running') return
259 G.status = 'met'
260 G.endedAt = now
261 G.active = false
262 G.finishedBy = how
263 await save($)
264 const took = minutes(G.endedAt - G.startedAt)
265 $.ui.toast(`${G.kind === 'plan' ? 'Plan' : 'Goal'} done in ${took}: ${clip(mask(G.title), 60)}`)
266 $.ui.invalidate('ui.render')
267 // the full band keeps FULL_MS, then it is one row: draw again just after
268 $.clock.after(FULL_MS + 200, async () => {
269 now = await $.clock.now()
270 $.ui.invalidate('ui.render')
271 })
272}
273
274async function serveTool($, e) {
275 now = await $.clock.now()
276 const action = String(e.action || 'show').toLowerCase()
277 const first = normalizeTasks(e.tasks)[0]
278 if (!G || (G.status !== 'running' && (action === 'plan' || action === 'add'))) {
279 if (!first) return { result: `Waypoints: no plan in this chat yet. Call action "plan" with the tasks first, each { "title": "...", "size": "S" | "M" | "L" }.` }
280 // a plan outside /goal: tracked the same way, named after its goal line, else its first task
281 G = newGoal({ sessionId, condition: planSubject(e), now, cwd, kind: 'plan' })
282 hidden = false
283 }
284 const by = e.by ? String(e.by) : e.agentId ? agentNames.get(e.agentId) || 'agent' : ''
285 const firstPlan = action === 'plan' && !G.planAt
286 const r = applyAction(G, e, { now, by })
287 if (r.ok) {
288 await save($)
289 $.ui.invalidate('ui.render')
290 if (firstPlan && G.planAt) await maybeAutoOpen($)
291 }
292 return { result: r.text }
293}
294
295// The goal check's verdict. Its row reaches session.append with no content (the
296// payload is stored beside it, seen live 2026-10-02), so read the record from
297// the end of the chat's log: a few lines, never the whole file (logs reach 200 MB).
298async function lastGoalStatus($, since) {
299 const path = transcriptPath
300 if (!path) return null
301 const windows = /^[A-Za-z]:/.test(path)
302 const argv = windows
303 ? ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', `Get-Content -LiteralPath '${path.replace(/'/g, "''")}' -Tail 12 -Encoding UTF8`]
304 : ['tail', '-n', '12', path]
305 let out = ''
306 try {
307 const r = await $.process.run(argv, { timeoutMs: 15000 })
308 out = r.stdout || ''
309 } catch {
310 return null
311 }
312 const lines = out.split(/\r?\n/).filter((l) => l.includes('"goal_status"')).reverse()
313 for (const line of lines) {
314 try {
315 const row = JSON.parse(line)
316 const a = row.attachment
317 if (!a || a.type !== 'goal_status') continue
318 // an older check's record is not this check's verdict
319 const ts = Date.parse(row.timestamp || '') || 0
320 return ts && since && ts < since - 3000 ? null : a
321 } catch {
322 // a line cut by the tail
323 }
324 }
325 return null
326}
327
328// The log is written a moment after the row reaches the hook: read it now, and
329// again after 1.5 and 4 seconds when the verdict isn't there yet
330async function onCheck($, message, at, attempt = 0) {
331 if (!G || G.kind !== 'goal' || G.status !== 'running') return
332 let verdict = await lastGoalStatus($, at)
333 if (!verdict) {
334 // a build that renders the verdict into the row itself
335 const blocks = Array.isArray(message.content) ? message.content : []
336 const c = parseCheck(blocks.filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n'))
337 verdict = c.met ? { met: true } : c.notMet ? { met: false, reason: c.reason } : null
338 }
339 if (!verdict && attempt < 2) {
340 $.clock.after(attempt ? 4000 : 1500, async () => {
341 now = await $.clock.now()
342 await onCheck($, message, at, attempt + 1).catch(() => {})
343 })
344 return
345 }
346 if (!verdict || verdict.sentinel) return // the row written when the goal is set
347 if (verdict.met) return finishGoal($, 'check')
348 G.check = { met: false, reason: clip(verdict.reason || 'no reason given', 300), at: now }
349 G.checks += 1
350 await save($)
351 $.ui.invalidate('ui.render')
352}
353
354export function register(on, opts) {
355 options = { palette: 'follow', animation: 'follow', pane: 'auto', ...(opts || {}) }
356 look = resolveLook(options, [])
357 roles = rolesOf(look.palette)
358
359 on('session.start', async ($, e, next) => {
360 now = await $.clock.now()
361 home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
362 win = (await $.env.get('OS')) === 'Windows_NT'
363 sessionId = await $.session.id()
364 cwd = await $.session.cwd()
365 // where Claude Code keeps this chat's log; a settings-hook event confirms it below
366 transcriptPath = `${home}/.claude/projects/${String(cwd).replace(/[^A-Za-z0-9]/g, '-')}/${sessionId}.jsonl`
367 const saved = await $.store.get('settings')
368 if (saved && typeof saved === 'object') settings = { ...settings, ...saved }
369 await readRecording($)
370 try {
371 const reg = await $.tool.register(TOOL_SPEC)
372 if (reg && reg.tool) toolName = reg.tool
373 } catch (err) {
374 toolFailed = true
375 $.ui.log(`waypoints: the task tool did not register (${err && err.message ? err.message : err})`)
376 }
377 commandName = (await registerCommand($)) || commandName
378 await restore($)
379 await refreshLook($)
380 await refreshPanes($)
381 $.clock.every(15000, async () => {
382 now = await $.clock.now()
383 // the fallback for a /config change of Agent-Pulse's that never reached config.set
384 await refreshLook($)
385 await refreshPanes($)
386 if (paneUp) await loadOthers($)
387 await maybeAutoClose($)
388 if (paneUp || (G && (G.status === 'running' || isRecent(G)))) $.ui.invalidate('ui.render')
389 })
390 $.clock.every(10000, () => readRecording($).catch(() => {}))
391 return next(e)
392 })
393
394 // The standing ask for multi-step work outside a /goal, so the global CLAUDE.md
395 // needs no line for it. A session section: it sits after the cache boundary and
396 // reads the same every request. Left out where no one watches the bar (-p, the
397 // bare prompt), for teammates (they must not replace the lead's plan), and when
398 // the tool did not register or /waypoints auto off.
399 on('prompt.compose', async ($, e, next) => {
400 const r = await next(e)
401 const traits = e.traits || []
402 if (!settings.auto || toolFailed || traits.some((t) => t === 'bare' || t === 'print' || t === 'teammate')) return r
403 return { sections: [...r.sections, { id: 'waypoints:plan', text: standing(toolName), scope: 'session' }] }
404 })
405
406 // /goal <condition> starts the meter and asks Claude to plan. The ask rides as
407 // the command's hidden note, so the cached prefix is untouched.
408 on('command.run', { command: 'goal' }, async ($, e, next) => {
409 const args = String(e.args || '').trim()
410 const r = await next(e)
411 now = await $.clock.now()
412 pendingGoal = null
413 if (!args) return r
414 if (isStopWord(args)) {
415 await stopGoal($, 'stopped')
416 return r
417 }
418 await startGoal($, args)
419 const notes = r && Array.isArray(r.context) ? r.context : []
420 return { ...(r || {}), context: [...notes, instruction(toolName)] }
421 })
422
423 // Fallback when /goal reaches the session without a command.run: remember it
424 // here and start at turn.start, asking for the plan with an appended note.
425 on('prompt.submit', async ($, e, next) => {
426 const m = String(e.text || '').match(/^\s*\/goal\s+([\s\S]+)$/)
427 if (m && !isStopWord(m[1])) pendingGoal = { args: m[1].trim(), at: await $.clock.now() }
428 return next(e)
429 })
430
431 on('turn.start', async ($, e, next) => {
432 now = await $.clock.now()
433 if (pendingGoal && (!G || G.startedAt < pendingGoal.at)) {
434 const args = pendingGoal.args
435 await startGoal($, args)
436 try {
437 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: instruction(toolName) }] } })
438 } catch {
439 // the nudge after a few tool calls is the second chance
440 }
441 }
442 pendingGoal = null
443 // closed on its tasks, but the goal loop carries on without a new prompt: reopen
444 if (G && G.status === 'met' && G.finishedBy === 'tasks' && G.kind === 'goal' && !String(e.text || '').trim() && now - G.endedAt < REOPEN_MS) {
445 G.status = 'running'
446 G.endedAt = 0
447 G.finishedBy = ''
448 }
449 if (G && G.status === 'running') {
450 G.active = true
451 G.interrupted = false
452 }
453 return next(e)
454 })
455
456 // The settings hooks' events carry the log's real path
457 on('classic.Stop', async ($, e, next) => {
458 if (e && typeof e.transcript_path === 'string' && e.transcript_path) transcriptPath = e.transcript_path
459 return next(e)
460 })
461
462 on('tool.call', async ($, e, next) => {
463 if (e.tool === toolName) return serveTool($, e)
464 if (G && G.status === 'running' && G.kind === 'goal' && !G.planAt && !e.agentId) {
465 // always on, but never asks for a tool that did not register
466 if (!toolFailed && WRITERS.has(e.tool)) return { deny: strictDeny(toolName) }
467 if (e.tool !== 'ToolSearch') callsWithoutPlan += 1
468 if (callsWithoutPlan >= NUDGE_AFTER && !nudged) {
469 nudged = true
470 try {
471 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: nudge(toolName) }] } })
472 } catch {
473 // the band says the plan is missing either way
474 }
475 $.ui.invalidate('ui.render')
476 }
477 }
478 return next(e)
479 })
480
481 on('agent.spawn', async ($, e, next) => {
482 const r = await next(e)
483 if (r && r.agentId) {
484 agentNames.set(r.agentId, clip(e.name || e.description || e.subagentType || 'agent', 32))
485 runningAgents.add(r.agentId)
486 if (G && G.status === 'running') $.ui.invalidate('ui.render')
487 }
488 return r
489 })
490
491 // The goal check writes its verdict as an attachment row
492 on('session.append', { door: 'attachment' }, async ($, e, next) => {
493 const r = await next(e)
494 if (!e.agentId && e.message && e.message.name === 'goal_status') {
495 now = await $.clock.now()
496 try {
497 await onCheck($, e.message, now)
498 } catch {
499 // the row is stored whatever the parser makes of it
500 }
501 }
502 return r
503 })
504
505 on('turn.complete', async ($, e, next) => {
506 const r = await next(e)
507 now = await $.clock.now()
508 if (e.agentId) {
509 runningAgents.delete(e.agentId)
510 if (G && G.status === 'running') $.ui.invalidate('ui.render')
511 return r
512 }
513 if (G && G.status === 'running') {
514 G.active = false
515 G.lastTurnEnd = now
516 if (e.isAborted) G.interrupted = true
517 const p = progress(G)
518 // the turn ended with every task done: finished (the goal check's own
519 // verdict, read from the log, usually closed it a moment earlier)
520 if (e.reason === 'answer' && !e.isAborted && p.n > 0 && p.doneN === p.n) await finishGoal($, 'tasks')
521 else await save($)
522 $.ui.invalidate('ui.render')
523 }
524 return r
525 })
526
527 on('command.run', { command: ['waypoints', 'waypoints-mod'] }, async ($, e) => {
528 now = await $.clock.now()
529 const [key, value] = String(e.args || '').trim().toLowerCase().split(/\s+/)
530 if (key === 'auto') {
531 settings.auto = value !== 'off'
532 await $.store.set('settings', settings)
533 $.ui.toast(`Standing plan ask ${settings.auto ? 'on: Claude plans any multi-step work' : 'off: only a /goal asks for a plan'}`)
534 return {}
535 }
536 if (key === 'hide' || key === 'show') {
537 hidden = key === 'hide'
538 $.ui.invalidate('ui.render')
539 $.ui.toast(hidden ? `Waypoints band hidden · /${commandName} show brings it back` : 'Waypoints band shown')
540 return {}
541 }
542 if (key === 'clear') {
543 await stopGoal($, 'stopped')
544 return {}
545 }
546 if (key === 'prune') {
547 const msg = await prune($)
548 if (!(await $.session.surface())) return { text: msg }
549 $.ui.toast(msg)
550 return {}
551 }
552 const surface = await $.session.surface()
553 if (!surface) return { text: plainText() }
554 await openPane($)
555 return {}
556 })
557
558 // The pane closing: a close by the person is remembered for this goal, so the next plan
559 // does not reopen it; the engine dropping it (unload) is not the person's doing
560 on('ui.close', async ($, e, next) => {
561 if (e.id === PANE) {
562 paneUp = false
563 autoPane.closed(e.origin, G)
564 }
565 return next(e)
566 })
567
568 // Another plugin's /config change that Waypoints follows: the new value is in the rows
569 // once the change has gone through
570 // (literals, so the engine can read the matcher off the source; FOLLOW_KEYS names the same two)
571 on('config.set', { key: ['agent-pulse.palette', 'agent-pulse.animation'] }, async ($, e, next) => {
572 const r = await next(e)
573 if (!r || !r.deny) await refreshLook($)
574 return r
575 })
576
577 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
578 const below = await next(e)
579 if (e.props && e.props.hasSurvey) return below
580 if (!bandUp()) return below
581 const el = $.ui.resolve(e)
582 // a Client names its module as a literal here, where the engine reads it from
583 const pulse = el.Client ? (key, props, width) => el.Client({ key, module: './pulse.mjs', props, width, height: 1 }) : undefined
584 const view = {
585 g: G,
586 now,
587 mask,
588 roles,
589 level: look.level,
590 width: Math.max(40, (e.props && e.props.bodyColumns) || 100),
591 maxRows: e.props && e.props.maxRows,
592 nudged,
593 pulse,
594 svg: e.surface !== 'terminal',
595 open: () => openPane($),
596 }
597 const mine = drawBand(el, view)
598 return el.Box({ flexDirection: 'column', children: below ? [mine, below] : [mine] })
599 })
600
601 // The footer shows even when the band is hidden or collapsed. Desktop does not
602 // draw the labels in `modes` (this one never showed there), only a tree, so
603 // there the engine's footer is wrapped, as arsenal's launcher and ctx do. The
604 // band shows the plan on Desktop, so the label is drawn only while it is away
605 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
606 const label = footerLabel()
607 if (!label) return next(e)
608 if (e.surface !== 'terminal') {
609 if (bandUp()) return next(e)
610 const footer = await next(e)
611 const { Box, Text } = $.ui.resolve(e)
612 return Box({ key: 'waypoints-footer', children: [Text({ children: [label] }), Text({ children: [' '] }), footer] })
613 }
614 const modes = Array.isArray(e.props && e.props.modes) ? e.props.modes : []
615 return next({ ...e, props: { ...e.props, modes: [...modes, label] } })
616 })
617
618 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
619 if (e.requestId !== PANE) return next(e)
620 const el = $.ui.resolve(e)
621 const width = Math.max(50, (e.props && e.props.bodyColumns) || 100)
622 const files = { count: fileCount, dir: shownPath(home + DIR) }
623 // the running task twinkles in the pane too: the same Client as the band's route
624 const pulse = el.Client ? (key, props, w) => el.Client({ key, module: './pulse.mjs', props, width: w, height: 1 }) : undefined
625 return drawPane(el, { g: G, now, mask, roles, level: look.level, width, others, sessionId, commandName, settings, files, pulse, svg: e.surface !== 'terminal' })
626 })
627}
628
629async function openPane($) {
630 now = await $.clock.now()
631 await loadOthers($)
632 autoPane.takenOver() // the person asked: it is theirs now
633 paneUp = true
634 await $.ui.open({ id: PANE, title: 'Waypoints', focus: true, closeOnEscape: true })
635}
636
637// ---------- words ----------
638
639// Whether the band draws: a goal that runs or ended lately, and not hidden
640function bandUp() {
641 return !!G && !hidden && (G.status === 'running' || isRecent(G))
642}
643
644function footerLabel() {
645 if (!G) return ''
646 const word = G.kind === 'plan' ? 'plan' : 'goal'
647 if (G.status === 'running') {
648 if (!G.planAt) return `◎ ${word} · planning`
649 const p = progress(G)
650 const t = eta(G, now)
651 return `◎ ${word} ${p.pct}%` + (t ? ` · ~${minutes(t.ms)}` : ` · ${p.doneN}/${p.n}`)
652 }
653 if (isRecent(G)) return G.status === 'met' ? `◎ ${word} done ✓` : `◎ ${word} stopped`
654 return ''
655}
656
657const ICON = { done: '✓', active: '▶', pending: '○', dropped: '×', parked: '⏸' }
658
659function taskTail(t) {
660 if (t.status === 'done') return t.doneAt > t.startedAt ? minutes(t.doneAt - t.startedAt) : ''
661 if (t.status === 'active') return (t.by ? t.by + ' · ' : '') + 'running ' + minutes(now - t.startedAt)
662 if (t.status === 'dropped') return 'dropped' + (t.note ? ': ' + t.note : '')
663 if (t.status === 'parked') return 'parked: ' + (t.note || '')
664 return ''
665}
666
667// /waypoints where no surface can draw a pane (a -p run)
668function plainText() {
669 if (!G) return 'No goal in this chat.'
670 const p = progress(G)
671 const lines = [`${label(G)}: ${mask(G.title)}`, `${statsLine(G, p, now, mask)} ${bar(p.fraction, 30)}`]
672 for (const t of G.tasks.filter((x) => !x.replaced)) lines.push(`${ICON[t.status] || '○'} ${t.size} ${mask(t.title)} ${mask(taskTail(t))}`)
673 return lines.filter(Boolean).join('\n')
674}
675hooks/fmt.mjs 70 lines1// Shared source. Copied into each mod's hooks/ folder by _dev/sync-shared.mjs.
2
3export function tokens(n) {
4 const v = Number(n) || 0
5 if (v >= 1e6) return (v / 1e6).toFixed(v >= 1e7 ? 0 : 1) + 'M'
6 if (v >= 1e3) return (v / 1e3).toFixed(v >= 1e5 ? 0 : 1) + 'k'
7 return String(Math.round(v))
8}
9
10export function usd(n) {
11 const v = Number(n) || 0
12 if (v === 0) return '$0'
13 if (v < 0.01) return '<$0.01'
14 if (v < 10) return '$' + v.toFixed(2)
15 return '$' + v.toFixed(0)
16}
17
18export function duration(ms) {
19 const s = Math.max(0, Math.round((Number(ms) || 0) / 1000))
20 if (s < 60) return s + 's'
21 const m = Math.floor(s / 60)
22 if (m < 60) return m + 'm' + String(s % 60).padStart(2, '0') + 's'
23 const h = Math.floor(m / 60)
24 return h + 'h' + String(m % 60).padStart(2, '0') + 'm'
25}
26
27export function minutes(ms) {
28 const m = Math.round((Number(ms) || 0) / 60000)
29 if (m <= 60) return m + 'm'
30 return Math.floor(m / 60) + 'h' + String(m % 60).padStart(2, '0') + 'm'
31}
32
33export function clock(ts) {
34 const d = new Date(ts)
35 let h = d.getHours()
36 const ampm = h >= 12 ? 'pm' : 'am'
37 h = h % 12 || 12
38 return h + ':' + String(d.getMinutes()).padStart(2, '0') + ampm
39}
40
41const SPARKS = '▁▂▃▄▅▆▇█'
42
43export function sparkline(values, width = 12) {
44 const vals = values.slice(-width)
45 if (vals.length === 0) return ''
46 const max = Math.max(...vals, 1)
47 return vals.map((v) => SPARKS[Math.min(7, Math.floor((v / max) * 7.999))]).join('')
48}
49
50export function bar(fraction, width = 20, full = '█', empty = '░') {
51 const f = Math.max(0, Math.min(1, Number(fraction) || 0))
52 const n = Math.round(f * width)
53 return full.repeat(n) + empty.repeat(width - n)
54}
55
56export function clip(text, max) {
57 const s = String(text ?? '').replace(/\s+/g, ' ').trim()
58 return s.length > max ? s.slice(0, Math.max(0, max - 1)) + '…' : s
59}
60
61export function pad(text, width) {
62 const s = String(text ?? '')
63 return s.length >= width ? s.slice(0, width) : s + ' '.repeat(width - s.length)
64}
65
66export function basename(path) {
67 const parts = String(path || '').split(/[\\/]+/).filter(Boolean)
68 return parts[parts.length - 1] || String(path || '')
69}
70hooks/privacy.mjs 337 lines1// Masks secrets, personal details, and business figures for screen recording.
2// Pure functions: no mods API calls here. The same file ships in each mod that
3// draws text, since a mod may import only its own files.
4
5const DOTS = '••••••••'
6const HIDE = '•••'
7const HIDDEN_OUTPUT = '••• hidden while recording'
8
9const SECRET_PATTERNS = [
10 /sk-ant-[A-Za-z0-9_-]{16,}/g,
11 /\bsk-(?:proj-|live-|test-|svcacct-)?[A-Za-z0-9_-]{20,}/g,
12 /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g,
13 /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
14 /\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
15 /\bAIza[0-9A-Za-z_-]{30,}/g,
16 /\bAKIA[0-9A-Z]{16}\b/g,
17 /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
18 /\b(?:hf|r8|pk_live|sk_live|rk_live|whsec|pat|key)_[A-Za-z0-9]{20,}/g,
19 /\bBearer\s+[A-Za-z0-9._~+/-]{20,}=*/gi,
20]
21
22// NAME=value or "name": "value" where the name looks like a credential
23const ASSIGNMENT =
24 /\b([A-Za-z0-9_.-]*(?:API[_-]?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE[_-]?KEY|ACCESS[_-]?KEY|CLIENT[_-]?SECRET|AUTH)[A-Za-z0-9_.-]*)(["']?\s*[=:]\s*["']?)([^\s"'`,;}{]{6,})/gi
25
26const EMAIL_SRC = '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}'
27const EMAIL = new RegExp(`\\b${EMAIL_SRC}\\b`, 'g')
28
29// People: "Jane Doe <jane@x.com>", "displayName": "Jane Doe", and From:/Attendees: lines
30const NAME_WORD = "[A-Z][\\p{L}'’.-]*"
31const DISPLAY_NAME = new RegExp(`(["']?)(${NAME_WORD}(?:[ \\t]+${NAME_WORD}){0,3})\\1([ \\t]*<[ \\t]*${EMAIL_SRC}[ \\t]*>)`, 'gu')
32const PERSON_KEY =
33 /("(?:displayName|display_name|fullName|full_name|firstName|first_name|lastName|last_name|givenName|given_name|familyName|family_name|real_name|realName|senderName|sender_name|authorName|author_name|username|user_name)"\s*:\s*")([^"]{1,80})(")/g
34const PERSON_LINE = /^([ \t>*-]*(?:From|To|Cc|Bcc|Reply-To|Attendees?|Organizer|Invitees?|Guests?|Assignees?|Sender|Owner)[ \t]*:[ \t]*)(\S.*)$/gm
35
36// Personal details
37const PHONE = /(?<![\w.])(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}(?![\w.])/g
38const PHONE_INTL = /(?<![\w.])\+\d{1,3}[\s.-]?\(?\d{1,4}\)?(?:[\s.-]?\d{2,4}){2,4}(?![\w.])/g
39const SSN = /(?<![\w-])\d{3}-\d{2}-\d{4}(?![\w-])/g
40const EIN = /(?<![\w-])\d{2}-\d{7}(?![\w-])/g
41const CARD = /(?<![\w-])(?:4\d{3}|5[1-5]\d{2}|2[2-7]\d{2}|3[47]\d{2}|6(?:011|5\d{2}))(?:[ -]?\d{2,4}){3,4}(?![\w-])/g
42const IBAN = /\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){3,7}(?: ?[A-Z0-9]{1,3})?\b/g
43const ACCOUNT = /\b((?:routing|account|acct|a\/c|aba|swift|bic|iban|passport|driver'?s license)(?:\s*(?:number|no\.?|num|#))?\s*[:#]?\s*)(\d[\d -]{3,}\d)/gi
44const CARD_ENDING = /\b((?:ending(?:\s+in)?|last\s+(?:4|four)(?:\s+digits)?)\s*[:#]?\s*)(\d{4})\b/gi
45const STREET = /\b\d{1,6}\s+(?:[NSEW]\.?\s+)?(?:[A-Z][\p{L}'.-]*\s+){1,3}(?:Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Drive|Dr|Lane|Ln|Way|Court|Ct|Place|Pl|Parkway|Pkwy|Highway|Hwy|Circle|Cir|Terrace|Ter|Trail|Trl|Square|Sq)\b\.?(?:,?\s+(?:Apt|Apartment|Suite|Ste|Unit|#)\.?\s*[\w-]+)?/gu
46const PO_BOX = /\bP\.?\s?O\.?\s+Box\s+\d+/gi
47const STATE_ZIP = /(,\s*(?:A[KLRZ]|C[AOT]|D[CE]|FL|GA|HI|I[ADLN]|K[SY]|LA|M[ADEINOST]|N[CDEHJMVY]|O[HKR]|PA|RI|S[CD]|T[NX]|UT|V[AT]|W[AIVY]))\s+\d{5}(?:-\d{4})?\b/g
48const BIRTH = /\b((?:DOB|D\.O\.B\.|date of birth|birth\s?date|birthday|born(?: on)?)\s*[:-]?\s*)([A-Za-z0-9 ,/.-]{4,20}\d)/gi
49const IPV4 = /(?<![\w.])((?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3})(?![\w.])/g
50
51// Money in any common currency
52const MONEY = /(?:US|CA|AU)?[$€£¥]\s?\d[\d,]*(?:\.\d+)?(?:\s?(?:[kKmMbB]|bn|million|thousand|billion)\b)?/g
53const MONEY_WORD = /(?<![\w.])(?:(?:USD|EUR|GBP|CAD|AUD)\s?\d[\d,]*(?:\.\d+)?(?:\s?(?:[kKmMbB]|bn|million|thousand|billion)\b)?|\d[\d,]*(?:\.\d+)?(?:\s?(?:[kKmMbB]|bn|million|thousand|billion))?\s?(?:USD|EUR|GBP|CAD|AUD|dollars|bucks)\b)/gi
54
55// Figures near business words: "MRR is 84k", "40% margin", "profit share 20%"
56const FIN_WORD =
57 /\b(?:revenues?|mrr|arr|gmv|profits?|profit share|margins?|ebitda|income|earnings|payroll|salar(?:y|ies)|wages?|compensation|comp|bonus(?:es)?|equity|stakes?|ownership|valuation|runway|burn(?: rate)?|cash(?:flow)?|balances?|budgets?|spend(?:ing)?|pric(?:e|es|ing)|fees?|invoices?|payouts?|distributions?|dividends?|tax(?:es)?|sales|ltv|cac|aov|sponsor(?:s|ships?)?|cpm|rpm|retainers?|commissions?|royalt(?:y|ies)|refunds?|expenses?|debts?|loans?|funding|investments?|deals?|net|gross|paid|pays?|owed?|costs?)\b/gi
58const FIGURE = /(?<![\w.])\d[\d,]*(?:\.\d+)?(?:\s?(?:%|percent\b|[kKmMbB]\b|bn\b|million\b|thousand\b|billion\b|x\b))?/g
59const BIG_FIGURE = /(?<![\w.])(?:\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?\s?(?:%|percent\b|[kKmMbB]\b|bn\b|million\b|thousand\b|billion\b))/g
60
61// Single names that are also ordinary words, and names that stay visible (the channel's own)
62const COMMON_WORDS = new Set(
63 'Will Mark Grant Bill Rich Art Hope Faith Joy May June April August Summer Rose Page Chase Hunter Max Ray Dawn Sky Brook Lane Dean Gene Guy Frank Sterling Major Price Young King Long Little Wood Hill Stone Field Ford Hall Bell Rice Banks Case Cook Fox Gray Green Brown White Black Day Lee West North South Love Church Park Street Bishop Mason Miller Baker Carter Cole Wells Hart Moon Star Van Von De Del La Le Da Di St Mr Mrs Ms Dr Jr Sr Claude Code Team Support Admin Info Hello The And Ai Slack Gmail Google Calendar Meeting Sync Notes Update Review Weekly Daily Monday Tuesday Wednesday Thursday Friday Saturday Sunday'.split(' '),
64)
65const ROLE_LOCAL_PARTS = /^(?:info|hello|hi|team|support|help|admin|noreply|no-reply|donotreply|contact|sales|billing|accounts?|notifications?|news|newsletter|marketing|office|mail|security|privacy|legal|press|jobs|careers|hr|ops|dev|bot|alerts?|updates?|calendar|invites?)$/i
66
67// Files that stay closed while recording. Add your own folders and file names
68// in ~/.claude/mods-data/recording-mode/config.json ("privatePaths"); /rec config makes one.
69const PRIVATE_PATHS = [
70 /(^|[\\/\s"'`])\.env(\.[A-Za-z0-9_-]+)?(?=$|[\s"'`;|&)])/i,
71 /\.credentials\.json/i,
72 /[\\/]\.claude\.json\b/i,
73 /\.claude[\\/]projects[\\/][^\\/\s"'`]+[\\/]memory/i,
74 /(^|[\\/\s"'`])\.(?:ssh|aws|gnupg)(?=$|[\\/])/i,
75 /\bid_(?:rsa|ed25519|ecdsa)\b/i,
76 /(^|[\\/\s"'`])\.(?:netrc|npmrc|pypirc)\b/i,
77 /\.(?:pem|p12|pfx)\b/i,
78 /taxes?[-_ ]?20\d\d/i,
79 /[\\/_-](?:payroll|invoices?|contracts?|financials?|finances?|budgets?|forecasts?|cap[-_ ]?table|term[-_ ]?sheets?|offer[-_ ]?letters?|business[-_ ]?plans?)(?=$|[\\/._\s"'`;|&)-])/i,
80 /(^|[\s"'`])(?:payroll|invoices?|contracts?|financials?|finances?|budgets?|forecasts?|business[-_ ]?plans?)(?=[\\/.])/i,
81]
82
83// Tools whose results are business data: drawn as hidden while recording
84const BUSINESS_TOOL =
85 /(?:^|__|_)(?:clickup|slack|gmail|outlook|notion|asana|linear|jira|clay|qbo|quickbooks|xero|fireflies|hubspot|salesforce|stripe|calendar|gcal|google_drive|drive|sheets)|search_threads|get_thread|get_message|list_drafts|get_draft|read_file_content|download_file_content|search_files|list_recent_files|get_file_metadata|profit_loss|cash_flow|balance_sheet|payroll|session_transcripts|export_transcript/i
86const BUSINESS_COMMAND = /\bgws(?:\.cmd|\.exe)?\b|fireflies|quickbooks/i
87// Finance tools stay closed while recording
88const FINANCE_TOOL = /__(?:qbo_|quickbooks|profit_loss|cash_flow|balance_sheet|benchmarking_quickbooks|money_onboarding|company_info)/i
89
90function escapeRegExp(s) {
91 return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
92}
93
94// Values from .env files: long enough to be secret, and not plain words or numbers.
95export function secretValuesFromEnv(text) {
96 const values = []
97 for (const line of String(text || '').split(/\r?\n/)) {
98 const m = line.match(/^\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.*)\s*$/)
99 if (!m) continue
100 let v = m[1].trim()
101 if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1)
102 if (v.length < 8) continue
103 if (/^(true|false|null|none|yes|no)$/i.test(v)) continue
104 if (/^\d+(\.\d+)?$/.test(v)) continue
105 if (/your[-_ ]?(key|token|secret)|here$|^<.*>$|^xxx/i.test(v)) continue
106 values.push(v)
107 }
108 return [...new Set(values)].sort((a, b) => b.length - a.length)
109}
110
111// The forms of one person's name that get masked: the full name, its slug, and each part that isn't an ordinary word
112function nameForms(full, keep) {
113 const clean = String(full || '').replace(/\s+/g, ' ').trim()
114 if (!clean || clean.length > 60 || keep.has(clean)) return []
115 const forms = new Set()
116 const parts = clean.split(/[ -]/).filter((p) => /^[A-Z][\p{L}'’.]*$/u.test(p) && p.length >= 2)
117 if (parts.length >= 2) {
118 forms.add(clean)
119 forms.add(clean.toLowerCase())
120 forms.add(clean.toLowerCase().replace(/ /g, '-'))
121 forms.add(clean.toLowerCase().replace(/ /g, '_'))
122 }
123 for (const p of parts) if (!COMMON_WORDS.has(p) && !keep.has(p)) forms.add(p)
124 return [...forms]
125}
126
127function namesFromEmail(email) {
128 const local = email.split('@')[0]
129 if (ROLE_LOCAL_PARTS.test(local)) return null
130 const parts = local.split(/[._+-]/).filter((p) => /^[a-z]{2,}$/i.test(p))
131 if (parts.length < 2 || parts.length > 4) return null
132 return parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()).join(' ')
133}
134
135// Masks numbers in the same clause as a business word
136function maskFigures(text, strict) {
137 if (strict) text = text.replace(BIG_FIGURE, HIDE)
138 const ranges = []
139 for (const m of text.matchAll(FIN_WORD)) {
140 const s = m.index
141 const e = s + m[0].length
142 let a = Math.max(0, s - 30)
143 let b = Math.min(text.length, e + 45)
144 const before = text.slice(a, s)
145 const stop = Math.max(before.lastIndexOf('\n'), before.lastIndexOf(';'), before.search(/[.!?]\s[^.!?]*$/))
146 if (stop >= 0) a += stop + 1
147 const after = text.slice(e, b)
148 const end = after.search(/\n|;|[.!?](?:\s|$)/)
149 if (end >= 0) b = e + end
150 ranges.push([a, b])
151 }
152 if (!ranges.length) return text
153 return text.replace(FIGURE, (fig, offset) => {
154 if (/^(?:19|20)\d\d$/.test(fig)) return fig // a year
155 return ranges.some(([a, b]) => offset >= a && offset < b) ? HIDE : fig
156 })
157}
158
159function luhn(digits) {
160 let sum = 0
161 for (let i = 0; i < digits.length; i++) {
162 let d = Number(digits[digits.length - 1 - i])
163 if (i % 2 === 1) {
164 d *= 2
165 if (d > 9) d -= 9
166 }
167 sum += d
168 }
169 return sum % 10 === 0
170}
171
172function publicIp(ip) {
173 const [a, b] = ip.split('.').map(Number)
174 if (a === 10 || a === 127 || a === 0 || ip === '255.255.255.255') return false
175 if (a === 192 && b === 168) return false
176 if (a === 172 && b >= 16 && b <= 31) return false
177 if (a === 169 && b === 254) return false
178 return true
179}
180
181// Your own name and its parts stay visible
182function keepSet(keepNames) {
183 const keep = new Set()
184 for (const n of keepNames || []) {
185 const clean = String(n || '').replace(/\s+/g, ' ').trim()
186 if (!clean) continue
187 keep.add(clean)
188 keep.add(clean.toLowerCase())
189 keep.add(clean.toLowerCase().replace(/ /g, '-'))
190 for (const part of clean.split(/[ -]/)) if (part) keep.add(part)
191 }
192 return keep
193}
194
195// strict: also every large figure (1,250 / 18% / 40k) wherever it appears
196// names: people to mask; keepNames: names that stay visible (yours)
197export function makeMasker({ strict = false, values = [], names = [], keepNames = [] } = {}) {
198 const KEEP_NAMES = keepSet(keepNames)
199 const valueRe = values.length
200 ? new RegExp(values.map(escapeRegExp).join('|'), 'g')
201 : null
202 // Names: the roster passed in plus any learned from emails and contact fields. Memory only.
203 const people = new Set()
204 let nameRe = null
205 let dirty = false
206 const learn = (full) => {
207 if (people.size > 2000) return
208 for (const f of nameForms(full, KEEP_NAMES)) {
209 if (!people.has(f)) {
210 people.add(f)
211 dirty = true
212 }
213 }
214 }
215 for (const n of names) learn(n)
216 const nameRegex = () => {
217 if (dirty) {
218 const list = [...people].sort((a, b) => b.length - a.length).map(escapeRegExp)
219 nameRe = list.length ? new RegExp(`(?<![\\p{L}\\p{N}_])(?:${list.join('|')})(?:['’]s)?(?![\\p{L}\\p{N}_])`, 'gu') : null
220 dirty = false
221 }
222 return nameRe
223 }
224
225 return function mask(text) {
226 if (typeof text !== 'string' || text.length === 0) return text
227 let out = text
228 if (valueRe) out = out.replace(valueRe, DOTS)
229 for (const re of SECRET_PATTERNS) out = out.replace(re, DOTS)
230 out = out.replace(ASSIGNMENT, (_, name, sep) => name + sep + DOTS)
231
232 // people, learned before the emails that name them are masked
233 for (const m of out.matchAll(EMAIL)) {
234 const n = namesFromEmail(m[0])
235 if (n) learn(n)
236 }
237 out = out.replace(DISPLAY_NAME, (_, q, name, addr) => {
238 learn(name)
239 return KEEP_NAMES.has(name) ? q + name + q + addr : HIDE + addr
240 })
241 out = out.replace(PERSON_KEY, (_, head, value, tail) => {
242 learn(value)
243 return head + (KEEP_NAMES.has(value) ? value : HIDE) + tail
244 })
245 out = out.replace(PERSON_LINE, (line, label, value) => (KEEP_NAMES.has(value.trim()) ? line : label + HIDE))
246 out = out.replace(EMAIL, '•••@•••')
247 const re = nameRegex()
248 if (re) out = out.replace(re, HIDE)
249
250 // personal details
251 out = out.replace(CARD, (m) => {
252 const digits = m.replace(/\D/g, '')
253 return digits.length >= 13 && digits.length <= 19 && luhn(digits) ? '•••• •••• •••• ••••' : m
254 })
255 out = out.replace(SSN, '•••-••-••••')
256 out = out.replace(EIN, '••-•••••••')
257 out = out.replace(IBAN, HIDE)
258 out = out.replace(ACCOUNT, (_, head) => head + HIDE)
259 out = out.replace(CARD_ENDING, (_, head) => head + '••••')
260 out = out.replace(BIRTH, (_, head) => head + HIDE)
261 out = out.replace(STREET, HIDE)
262 out = out.replace(PO_BOX, HIDE)
263 out = out.replace(STATE_ZIP, (_, head) => head + ' •••••')
264 out = out.replace(PHONE, '•••-•••-••••')
265 out = out.replace(PHONE_INTL, '+•• •••')
266 out = out.replace(IPV4, (ip) => (publicIp(ip) ? '•••.•••.•••.•••' : ip))
267
268 // business figures
269 out = out.replace(MONEY, '$•••')
270 out = out.replace(MONEY_WORD, HIDE)
271 out = maskFigures(out, strict)
272 return out
273 }
274}
275
276// Masks every string inside plain data, keeping the shape.
277export function deepMask(value, mask, depth = 0) {
278 if (depth > 12) return value
279 if (typeof value === 'string') return mask(value)
280 if (Array.isArray(value)) return value.map((v) => deepMask(v, mask, depth + 1))
281 if (value && typeof value === 'object') {
282 const proto = Object.getPrototypeOf(value)
283 if (proto !== Object.prototype && proto !== null) return value
284 const out = {}
285 for (const [k, v] of Object.entries(value)) out[k] = deepMask(v, mask, depth + 1)
286 return out
287 }
288 return value
289}
290
291// Hides the readable text inside a business tool's result, keeping short tags (type: 'text') so the row still draws.
292export function hideText(s) {
293 if (typeof s !== 'string' || s.length === 0) return s
294 return s.length > 24 || /\s/.test(s) ? HIDDEN_OUTPUT : s
295}
296
297// extra: your own folder or file names from the config file, matched anywhere in a path, any case
298export function privatePathHit(text, extra = []) {
299 const s = String(text || '')
300 for (const re of PRIVATE_PATHS) {
301 const m = s.match(re)
302 if (m) return m[0].trim().replace(/^["'`\\/_-]/, '')
303 }
304 const flat = s.replace(/\\/g, '/').toLowerCase()
305 for (const item of extra) {
306 const needle = String(item || '').replace(/\\/g, '/').toLowerCase().trim()
307 if (needle && flat.includes(needle)) return String(item)
308 }
309 return null
310}
311
312// The strings in a tool call's arguments that could name a file or carry a command.
313export function toolCallTargets(e) {
314 const keys = ['file_path', 'path', 'pattern', 'glob', 'command', 'notebook_path', 'url']
315 const out = []
316 for (const k of keys) if (typeof e?.[k] === 'string') out.push(e[k])
317 return out
318}
319
320// Whether a tool's result is business data (mail, chat, tasks, files, calendar, finance)
321// extra: more tool or command names (any part of the name) whose results draw as hidden
322export function businessSource(tool, input, extra = []) {
323 const name = String(tool || '')
324 if (BUSINESS_TOOL.test(name)) return true
325 const cmd = input && typeof input.command === 'string' ? input.command : ''
326 const hay = (name + ' ' + cmd).toLowerCase()
327 if (extra.some((x) => x && hay.includes(String(x).toLowerCase()))) return true
328 const command = input && typeof input.command === 'string' ? input.command : ''
329 return command ? BUSINESS_COMMAND.test(command) : false
330}
331
332// extra: more tool names (any part of the name) that stay closed while recording
333export function financeTool(tool, extra = []) {
334 const name = String(tool || '')
335 return FINANCE_TOOL.test(name) || extra.some((x) => x && name.toLowerCase().includes(String(x).toLowerCase()))
336}
337hooks/plan.mjs 299 lines1// Waypoints' plan: the task list Claude keeps through the mod's own tool, and
2// what the bar, the ETA, and the goal check read from it. Pure functions: no mods
3// API calls here, so the harness tests can drive them directly.
4
5export const SIZES = { S: 1, M: 2, L: 3 }
6
7const STOP = /^(clear|stop|off|cancel|end|none|reset)$/i
8
9export function isStopWord(text) {
10 return STOP.test(String(text || '').trim())
11}
12
13export function titleOf(condition) {
14 const line = String(condition || '').split(/\r?\n/).map((l) => l.trim()).find(Boolean) || 'Goal'
15 return line.length > 160 ? line.slice(0, 159) + '…' : line
16}
17
18// What a plan made outside /goal is named after: the one line Claude gives as "goal",
19// else the first task (which names a step, not the whole plan)
20export function planSubject(input) {
21 const goal = String(input.goal || '').trim()
22 return goal ? titleOf(goal) : titleOf(normalizeTasks(input.tasks)[0]?.title)
23}
24
25export function newGoal({ sessionId, condition, now, cwd, kind = 'goal' }) {
26 return {
27 sessionId,
28 kind,
29 title: titleOf(condition),
30 condition: String(condition || '').slice(0, 2000),
31 cwd: cwd || '',
32 startedAt: now,
33 planAt: 0,
34 endedAt: 0,
35 status: 'running',
36 active: true,
37 lastTurnEnd: 0,
38 interrupted: false,
39 tasks: [],
40 nextId: 1,
41 firstPlan: 0,
42 check: null,
43 checks: 0,
44 updatedAt: now,
45 }
46}
47
48function sizeOf(v) {
49 const s = String(v || '').trim().toUpperCase().charAt(0)
50 return SIZES[s] ? s : 'M'
51}
52
53function clean(text, max) {
54 const s = String(text ?? '').replace(/\s+/g, ' ').trim()
55 return s.length > max ? s.slice(0, max - 1) + '…' : s
56}
57
58export function weight(t) {
59 return SIZES[t.size] || 2
60}
61
62// Tasks still part of the plan: dropped and replaced ones don't count
63export function live(goal) {
64 return goal.tasks.filter((t) => t.status !== 'dropped' && !t.replaced)
65}
66
67// What the numbers count: a parked task is still drawn, but waits on someone outside
68// the chat, so it is left out of the total and the plan can finish without it
69export function progress(goal) {
70 const all = live(goal)
71 const parked = all.filter((t) => t.status === 'parked')
72 const tasks = all.filter((t) => t.status !== 'parked')
73 const total = tasks.reduce((a, t) => a + weight(t), 0)
74 const done = tasks.filter((t) => t.status === 'done')
75 const doneW = done.reduce((a, t) => a + weight(t), 0)
76 return {
77 total,
78 doneW,
79 doneN: done.length,
80 n: tasks.length,
81 parked,
82 active: tasks.filter((t) => t.status === 'active'),
83 pct: total ? Math.round((100 * doneW) / total) : 0,
84 fraction: total ? doneW / total : 0,
85 }
86}
87
88// The ETA at this goal's own pace: time since the plan per unit of finished
89// work, times the work left. Nothing until two tasks are done.
90export function eta(goal, now) {
91 const p = progress(goal)
92 if (goal.status !== 'running' || p.doneN < 2 || p.doneW <= 0 || p.doneW >= p.total) return null
93 const since = goal.planAt || goal.startedAt
94 const per = Math.max(0, now - since) / p.doneW
95 const ms = Math.round(per * (p.total - p.doneW))
96 return { ms, at: now + ms }
97}
98
99// Accepts [{ title, size }], plain strings, or the same as a JSON string
100export function normalizeTasks(raw) {
101 let list = raw
102 if (typeof list === 'string') {
103 try {
104 list = JSON.parse(list)
105 } catch {
106 list = list.split(/\r?\n/)
107 }
108 }
109 if (!Array.isArray(list)) return []
110 const out = []
111 for (const item of list) {
112 if (typeof item === 'string') {
113 const title = clean(item.replace(/^\s*[-*\d.)\]]+\s*/, ''), 140)
114 if (title) out.push({ title, size: 'M' })
115 continue
116 }
117 if (!item || typeof item !== 'object') continue
118 const title = clean(item.title ?? item.subject ?? item.name ?? item.task ?? item.description, 140)
119 if (title) out.push({ title, size: sizeOf(item.size) })
120 }
121 return out.slice(0, 60)
122}
123
124function idsOf(input) {
125 const raw = Array.isArray(input.ids) ? input.ids : input.id !== undefined ? [input.id] : []
126 return raw.map((v) => Number(String(v).replace(/^#/, ''))).filter((n) => Number.isInteger(n) && n > 0)
127}
128
129function addTasks(goal, list, now, origin, rev = false) {
130 for (const t of list) {
131 goal.tasks.push({ id: goal.nextId++, title: t.title, size: t.size, status: 'pending', by: '', addedAt: now, startedAt: 0, doneAt: 0, origin, note: '', ...(rev ? { rev: true } : {}) })
132 }
133}
134
135// The task finished last, which a revision of the plan follows from (0 when none is)
136function lastDoneId(goal) {
137 const done = goal.tasks.filter((t) => t.status === 'done').sort((a, b) => b.doneAt - a.doneAt || b.id - a.id)
138 return done.length ? done[0].id : 0
139}
140
141const WORD = { pending: 'todo', active: 'running', done: 'done', dropped: 'dropped', parked: 'parked' }
142
143export function listText(goal) {
144 const p = progress(goal)
145 const parked = p.parked.length ? ` ${p.parked.length} parked (not counted).` : ''
146 const lines = [`Plan: ${p.doneN} of ${p.n} tasks done, ${p.pct}% of the work.${parked}`]
147 for (const t of goal.tasks) {
148 if (t.replaced) continue
149 const who = t.status === 'active' && t.by ? ` by ${t.by}` : ''
150 const why = t.status === 'parked' && t.note ? `: ${t.note}` : ''
151 lines.push(`#${t.id} ${WORD[t.status] || t.status} ${t.title} (${t.size})${who}${why}`)
152 }
153 return lines.join('\n')
154}
155
156// One call of the task tool. Returns { ok, text } and changes the goal in place.
157export function applyAction(goal, input, { now, by = '' } = {}) {
158 const action = String(input.action || 'show').toLowerCase()
159 const fail = (msg) => ({ ok: false, text: `Waypoints: ${msg}\n${listText(goal)}` })
160 if (action === 'plan' || action === 'add') {
161 const list = normalizeTasks(input.tasks)
162 if (!list.length) return fail(`"${action}" needs tasks: [{ "title": "...", "size": "S" | "M" | "L" }].`)
163 if (action === 'plan' && goal.planAt) {
164 // a new plan replaces the work not started yet; finished and running tasks stay
165 for (const t of goal.tasks) if (t.status === 'pending') t.replaced = true
166 }
167 const first = !goal.planAt
168 // a plan changed for a reason: what changed, and after which finished task
169 const note = first ? '' : clean(input.note, 140)
170 if (note) {
171 goal.revisions = [...(goal.revisions || []), { at: now, after: lastDoneId(goal), note }].slice(-20)
172 }
173 addTasks(goal, list, now, first ? 'plan' : 'later', !!note)
174 if (first) {
175 goal.planAt = now
176 goal.firstPlan = list.length
177 }
178 } else if (action === 'start' || action === 'done' || action === 'drop' || action === 'park') {
179 const ids = idsOf(input)
180 if (!ids.length) return fail(`"${action}" needs the task's id.`)
181 if (action === 'park' && !clean(input.note, 140)) return fail('"park" needs a "note" naming who or what the task waits on.')
182 const tasks = ids.map((id) => goal.tasks.find((t) => t.id === id && !t.replaced))
183 const missing = ids.filter((id, i) => !tasks[i])
184 if (missing.length) return fail(`no task #${missing.join(', #')}.`)
185 for (const t of tasks) {
186 if (action === 'start') {
187 // a parked task taken up again loses its blocker
188 if (t.status === 'parked') t.note = ''
189 t.status = 'active'
190 t.startedAt = t.startedAt || now
191 if (by) t.by = clean(by, 32)
192 } else if (action === 'park') {
193 t.status = 'parked'
194 t.note = clean(input.note, 140)
195 } else if (action === 'done') {
196 t.status = 'done'
197 t.startedAt = t.startedAt || now
198 t.doneAt = now
199 if (input.note) t.note = clean(input.note, 140)
200 } else {
201 t.status = 'dropped'
202 t.doneAt = now
203 t.note = clean(input.note || 'no longer needed', 140)
204 }
205 }
206 } else if (action !== 'show') {
207 return fail(`unknown action "${action}". Use plan, add, start, done, drop, park, or show.`)
208 }
209 goal.updatedAt = now
210 return { ok: true, text: listText(goal) }
211}
212
213// The goal check's verdict, from the text the engine records for it. The shape
214// is not documented, so this reads both a JSON-ish record and plain words.
215export function parseCheck(text) {
216 const t = String(text || '')
217 const notMet = /"met"\s*:\s*false|\bmet\s*[=:]\s*false|\bnot\s+(?:yet\s+)?(?:been\s+)?(?:met|achieved|satisfied|complete)|\bunmet\b|\bnot\s+satisfied\b/i.test(t)
218 const met = !notMet && /"met"\s*:\s*true|\bmet\s*[=:]\s*true|\b(?:condition|goal)\b[^.\n]{0,60}\b(?:is|was|has been)\s+(?:met|achieved|satisfied)\b/i.test(t)
219 const m = t.match(/"reason"\s*:\s*"((?:[^"\\]|\\.)*)"/) || t.match(/\breason\b[^:\n]{0,20}:\s*([\s\S]+)/i)
220 const reason = clean(m ? m[1].replace(/\\"/g, '"').replace(/\\n/g, ' ') : '', 300)
221 return { met, notMet, reason }
222}
223
224export const TOOL_SPEC = {
225 name: 'tasks',
226 description:
227 'Waypoints: the task plan the user watches as a progress bar while a /goal runs. ' +
228 'Call action "plan" first with every task needed to meet the goal, in order, each sized S, M, or L. ' +
229 'Call "start" with a task id when you begin it (set "by" to a subagent\'s short name when one does it) and "done" when it is finished. ' +
230 'Call "add" for work you discover or when the goal check says the goal is not met yet, and "drop" for a task no longer needed. ' +
231 'Call "park" with a "note" naming the blocker for a task only someone outside this session can move (another team, a release window, a person who is away): it leaves the bar\'s total, so the plan can finish without it. ' +
232 'When a task turns out differently than the plan assumed, fix the tasks still to do ("plan" again replaces the pending ones, or "add" / "drop") with a "note" saying what changed. ' +
233 'Keep it accurate: the bar and the ETA come only from this list.',
234 inputSchema: {
235 type: 'object',
236 properties: {
237 action: { type: 'string', enum: ['plan', 'add', 'start', 'done', 'drop', 'park', 'show'] },
238 goal: { type: 'string', description: 'For plan: one line naming what the whole plan achieves, shown as its title' },
239 tasks: {
240 type: 'array',
241 description: 'For plan and add: the tasks in order.',
242 items: {
243 type: 'object',
244 properties: {
245 title: { type: 'string', description: 'What the task delivers, in a few words' },
246 size: { type: 'string', enum: ['S', 'M', 'L'], description: 'S: a few minutes. M: a solid chunk. L: the big piece.' },
247 },
248 required: ['title'],
249 },
250 },
251 id: { type: 'number', description: 'For start, done, drop, and park: the task number' },
252 ids: { type: 'array', items: { type: 'number' }, description: 'Several task numbers at once' },
253 by: { type: 'string', description: 'For start: who does it, "main" or the subagent\'s short name' },
254 note: { type: 'string', description: 'One short line. For park (required): who or what the task waits on. For plan again or add: what changed. For done or drop: the outcome or why.' },
255 },
256 required: ['action'],
257 },
258}
259
260// The two rules both asks carry: keep the later steps true to what happened, and never
261// leave the bar on an open task nobody explained
262const REVISE = `When a task turns out differently than the plan assumed, check the tasks still to do and fix them ("plan" again, "add" or "drop") with a "note" saying what changed. `
263const OPEN_END =
264 `Never end your turn with a task still open and unexplained: finish it, "park" it with a "note" naming the blocker when it waits on someone outside this session, ` +
265 `or, when you are unsure what it takes, ask the user with concrete options and your recommendation. `
266
267export function instruction(tool) {
268 return (
269 `Waypoints is on for this goal: the user watches your progress as a bar built from your task plan. ` +
270 `Before you start the work, call ${tool} (load it with ToolSearch if it is deferred) with action "plan" and every task needed to meet the goal, in order, each sized S, M, or L. ` +
271 `Call "start" with a task's id when you begin it and "done" when it is finished. ` +
272 `If you find more work, or the goal check says the goal is not met yet, call "add" with the new tasks. ` +
273 REVISE +
274 OPEN_END +
275 `When a subagent does a task, call "start" with "by" set to its short name, and "done" when it reports back.`
276 )
277}
278
279// The standing ask, kept in the system prompt: plan any multi-step work, not only
280// a /goal. It replaces the line the mod used to need in the global CLAUDE.md.
281export function standing(tool) {
282 return (
283 `Waypoints is installed: the user watches a progress bar built from your task plan. ` +
284 `For multi-step work (3 or more steps), call ${tool} (load it with ToolSearch if it is deferred) with action "plan" before you start, with "goal" set to one line naming what the whole plan achieves and every task in order, each sized S, M, or L. ` +
285 `Then call "start" with a task's id when you begin it and "done" when it is finished, and "add" work you find along the way. ` +
286 REVISE +
287 OPEN_END +
288 `Skip it for small single-step requests. If you are a subagent, never call "plan": it replaces the main agent's pending tasks.`
289 )
290}
291
292export function nudge(tool) {
293 return `Reminder from Waypoints: this /goal has no task plan yet, so the user's progress bar is empty. Call ${tool} with action "plan" now (every task to meet the goal, in order, sized S, M, or L), then mark tasks as you go.`
294}
295
296export function strictDeny(tool) {
297 return `Waypoints: plan the goal before changing files. Call ${tool} with action "plan" first, then retry this.`
298}
299hooks/palette.mjs 138 lines1// Waypoints' colours. The four palettes below are COPIED from agent-pulse/hooks/palette.ts
2// (each mod is installed alone, so there is no import across mods): keep the values in
3// step with Agent-Pulse. palette.test.ts pins a few, so a drift is a deliberate edit.
4// Only the phases Waypoints paints with are copied: idle, tool, agent, approval.
5
6const CLAUDE_HEX = { idle: '#8a8a8a', tool: '#d97757', agent: '#2ec4b6', approval: '#ffc107' }
7const EMBER_HEX = { idle: '#857a74', tool: '#d97757', agent: '#8cc7b5', approval: '#ffcf5c' }
8const EMBER_GLOW = { idle: '#9a8f88', tool: '#f3a97c', agent: '#b6d99a', approval: '#ffb347' }
9const NEON_HEX = { idle: '#5b6785', tool: '#22e5f0', agent: '#4ade80', approval: '#ffd166' }
10const NEON_GLOW = { idle: '#6f7a99', tool: '#6ef0c8', agent: '#bef264', approval: '#ff9f6e' }
11const MONO_HEX = { idle: '#7a7a7a', tool: '#d0d0d0', agent: '#d0d0d0', approval: '#ffc107' }
12
13const OUTCOME_HEX = { ok: '#4eba65', error: '#ff6b80', denied: '#ffc107', interrupted: '#8a8a8a' }
14const OUTCOME_THEME = { ok: 'success', error: 'error', denied: 'warning', interrupted: 'inactive' }
15const EMBER_OUTCOME = { ok: '#8fd694', error: '#ff6b6b', denied: '#ffcf5c', interrupted: '#857a74' }
16const NEON_OUTCOME = { ok: '#4ade80', error: '#ff5c7a', denied: '#ffd166', interrupted: '#6f7a99' }
17
18const SHIMMER_STEPS = 12
19
20// phase: what Text takes (a theme key where one means the same); hex/glow: the same colour as hex
21const PALETTES = {
22 ember: {
23 phase: EMBER_HEX,
24 hex: EMBER_HEX,
25 glow: EMBER_GLOW,
26 outcome: EMBER_OUTCOME,
27 sparkle: ['#d97757', '#e8957a', '#f3a97c', '#f6c177', '#ffe3b0', '#f6c177', '#f3a97c', '#e8957a'],
28 heat: ['#f6d36b', '#ff9a4d', '#ff5c5c'],
29 agents: ['#8cc7b5', '#f2b8a0', '#c6a4ec', '#f6c177', '#ee9aa4', '#9fc7e8'],
30 },
31 claude: {
32 phase: { idle: 'inactive', tool: 'claude', agent: CLAUDE_HEX.agent, approval: 'warning' },
33 hex: CLAUDE_HEX,
34 glow: CLAUDE_HEX,
35 outcome: OUTCOME_THEME,
36 sparkle: ['#d97757', '#e48a66', '#f0a47f', '#fbc8a8', '#ffe6d5', '#fbc8a8', '#f0a47f', '#e48a66'],
37 heat: ['#e0af68', '#ff9e64', 'error'],
38 agents: ['#2ec4b6', '#f4a261', '#a78bfa', '#60a5fa', '#f472b6', '#84cc16'],
39 },
40 neon: {
41 phase: NEON_HEX,
42 hex: NEON_HEX,
43 glow: NEON_GLOW,
44 outcome: NEON_OUTCOME,
45 sparkle: ['#22e5f0', '#5eead4', '#7dd3fc', '#b39dff', '#ff8ad8', '#c4a1ff', '#7aa2ff', '#38bdf8'],
46 heat: ['#ffd166', '#ff9f6e', '#ff5c7a'],
47 agents: ['#5eead4', '#ff8ad8', '#bef264', '#ffd166', '#b39dff', '#7dd3fc'],
48 },
49 mono: {
50 phase: { idle: 'inactive', tool: 'text', agent: 'text', approval: 'warning' },
51 hex: MONO_HEX,
52 glow: MONO_HEX,
53 outcome: OUTCOME_THEME,
54 sparkle: ['#6e6e6e', '#8a8a8a', '#a8a8a8', '#c8c8c8', '#e8e8e8', '#c8c8c8', '#a8a8a8', '#8a8a8a'],
55 heat: ['warning', 'warning', 'error'],
56 agents: ['#d0d0d0', '#a8a8a8', '#e8e8e8', '#8a8a8a'],
57 },
58}
59
60export const PALETTE_NAMES = ['ember', 'claude', 'neon', 'mono']
61export const LEVELS = ['off', 'subtle', 'full']
62
63export function paletteOf(name) {
64 return PALETTES[name === 'claude' || name === 'neon' || name === 'mono' ? name : 'ember']
65}
66
67export function levelOf(value) {
68 return value === 'off' || value === 'full' ? value : 'subtle'
69}
70
71// Mixes two hex colours, t 0 being a and 1 being b
72export function mix(a, b, t) {
73 const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16))
74 const from = channels(a)
75 const to = channels(b)
76 const k = Math.min(1, Math.max(0, t))
77 return '#' + from.map((c, i) => Math.round(c + (to[i] - c) * k).toString(16).padStart(2, '0')).join('')
78}
79
80// A phase's colour t of the way from its core to its glow. A flat palette keeps its own
81// colour, which may be a theme key.
82export function shadeOf(palette, phase, t) {
83 const core = palette.hex[phase]
84 const glow = palette.glow[phase]
85 return core === glow ? palette.phase[phase] : mix(core, glow, t)
86}
87
88// The spinner's colours, one per frame: core to glow and back, or the one colour of a flat palette
89export function shimmerOf(palette, phase) {
90 if (palette.hex[phase] === palette.glow[phase]) return [palette.phase[phase]]
91 return Array.from({ length: SHIMMER_STEPS }, (_, i) => shadeOf(palette, phase, (1 - Math.cos((i / SHIMMER_STEPS) * 2 * Math.PI)) / 2))
92}
93
94// One colour per agent, the same each time its name comes up (and the same Agent-Pulse gives it)
95export function agentColorOf(palette, name) {
96 let hash = 0
97 for (const char of String(name)) hash = (hash * 31 + char.charCodeAt(0)) | 0
98 return palette.agents[Math.abs(hash) % palette.agents.length] || palette.hex.agent
99}
100
101// The roles Waypoints draws with, mapped onto the palette's phase colours so a palette
102// means the same in both mods. `done(t)` shades along the route, t 0..1 (position / width).
103export function rolesOf(name) {
104 const p = paletteOf(name)
105 return {
106 name: PALETTE_NAMES.includes(name) ? name : 'ember',
107 done: (t) => shadeOf(p, 'tool', t),
108 active: shimmerOf(p, 'tool'),
109 activeStill: p.phase.tool,
110 pending: p.phase.idle,
111 attention: p.phase.approval,
112 ok: p.outcome.ok,
113 sparkle: p.sparkle,
114 heat: p.heat[0],
115 agent: (by) => agentColorOf(p, by),
116 // the SVG always takes hex, never a theme key
117 hex: { done: p.hex.tool, glow: p.glow.tool, pending: p.hex.idle, ok: p.hex.tool, sparkle: p.sparkle },
118 }
119}
120
121// The row Waypoints follows in /config; the key is confirmed live (see DESIGN.md)
122export const FOLLOW_KEYS = { palette: 'agent-pulse.palette', animation: 'agent-pulse.animation' }
123
124// The look to draw with. options: the userConfig values ('follow' or explicit); rows:
125// $.config.list(). 'follow' takes Agent-Pulse's own value, or ember/subtle without it.
126export function resolveLook(options, rows) {
127 const o = options || {}
128 const list = Array.isArray(rows) ? rows : []
129 const fromRow = (key) => {
130 const row = list.find((r) => r && r.key === key)
131 return row ? row.value : undefined
132 }
133 const palette = o.palette && o.palette !== 'follow' ? o.palette : fromRow(FOLLOW_KEYS.palette)
134 const animation = o.animation && o.animation !== 'follow' ? o.animation : fromRow(FOLLOW_KEYS.animation)
135 const name = PALETTE_NAMES.includes(palette) ? palette : 'ember'
136 return { palette: name, level: levelOf(animation) }
137}
138hooks/draw.mjs 410 lines1// Waypoints' drawing: the band above the prompt (and, below, the pane). Pure: every
2// function takes the element table `el` and a `view` and returns a tree, so the harness
3// can draw a band without the engine.
4//
5// view = {
6// g, the goal now, the clock
7// mask, Recording Mode's masker roles, rolesOf(palette)
8// level, 'off' | 'subtle' | 'full' width, bodyColumns
9// maxRows, rows the band may take nudged, Claude was reminded to plan
10// pulse, (key, props, width) → a terminal Client (built in register.mjs, which the
11// engine reads the module path from); absent, nothing animates
12// open, () → open the pane
13// svg, the surface paints Svg (Desktop, mobile): the engine's `e.surface` says so, as
14// the terminal's element table lists `Svg` too and cannot tell them apart
15// }
16// A surface that paints Svg gets the route as one Svg and a still spinner; the terminal
17// gets Text runs and the Client animations.
18
19import { minutes, clock, clip, basename } from './fmt.mjs'
20import { progress, eta, live, weight } from './plan.mjs'
21import { routeCells, miniRoute, runs, FLAG, FLAG_DONE, PARKED, RUNNING } from './route.mjs'
22import { routeSvg, routeAlt, svgWidth } from './svg.mjs'
23import { NOTE_AT } from './prune.mjs'
24import { VERSION } from './version.mjs'
25
26export const FULL_MS = 8000 // a finished band keeps all its rows this long
27export const SPIN = { frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], ms: 80 }
28
29// ---------- words ----------
30
31export function label(g) {
32 return g.kind === 'plan' ? 'Plan' : 'Goal'
33}
34
35export function isPaused(g) {
36 return g.status === 'running' && !g.active && g.lastTurnEnd > 0
37}
38
39// A task running past twice the plan's own pace for its size (only once the ETA exists)
40export function runsLong(g, t, now) {
41 if (!eta(g, now)) return false
42 const pace = (now - (g.planAt || g.startedAt)) / progress(g).doneW
43 return now - t.startedAt > 2 * pace * weight(t)
44}
45
46function byOf(t) {
47 return t.by && t.by !== 'main' ? t.by : ''
48}
49
50function lastDone(tasks) {
51 return tasks.filter((t) => t.status === 'done').sort((a, b) => b.doneAt - a.doneAt || b.id - a.id)[0]
52}
53
54// The parked tasks in a few words: one names its blocker (when `withNote`), more are counted
55function parkedWords(p, mask, withNote) {
56 const parked = p.parked || []
57 const n = parked.length
58 if (!n) return ''
59 const note = parked[0].note
60 return n === 1 && withNote && note ? `1 parked: ${mask(note)}` : `${n} parked`
61}
62
63// ---------- the route ----------
64
65function colorOf(roles, cell) {
66 if (cell.role === 'done') return roles.done(cell.t)
67 if (cell.role === 'sparkle') return roles.sparkle[0]
68 if (cell.role === 'active') return roles.activeStill
69 return roles.pending
70}
71
72function routeRow(el, v, cells, tail, tasks) {
73 const { Box, Text } = el
74 const finishing = v.g.status === 'met'
75 // Desktop draws the route as an Svg, which moves by CSS; only the terminal takes a Client
76 const svg = v.svg && el.Svg
77 const animate = v.pulse && !svg
78 const kids = []
79 if (svg) {
80 const p = progress(v.g)
81 const running = tasks.map((t, i) => (t.status === 'active' ? i + 1 : 0)).filter(Boolean)
82 const width = svgWidth(v.width)
83 const source = routeSvg(cells, { hex: v.roles.hex, level: v.level, width, finishId: finishing ? `${v.g.startedAt}-${v.g.endedAt}` : '' })
84 kids.push(el.Svg({ key: 'route', source, alt: routeAlt(p.doneN, p.n, running, p.parked.length), width, height: 14 }))
85 } else if (finishing && v.level === 'full' && animate) {
86 // one-shot, keyed by the finish: it sweeps the sparkle colours along the route once,
87 // then rests on the solid route
88 kids.push(
89 v.pulse(
90 `sweep-${v.g.startedAt}-${v.g.endedAt}`,
91 { kind: 'sweep', chars: cells.map((c) => c.ch).join(''), done: cells.map((c) => colorOf(v.roles, c)), colors: [...v.roles.sparkle] },
92 cells.length,
93 ),
94 )
95 } else {
96 let buf = []
97 const flush = () => {
98 for (const r of runs(buf, (c) => colorOf(v.roles, c))) kids.push(Text({ color: r.color, children: [r.text] }))
99 buf = []
100 }
101 for (const c of cells) {
102 if (c.role === 'active' && v.level !== 'off' && animate) {
103 flush()
104 kids.push(v.pulse(`node-${c.id}`, { kind: 'node', colors: [...v.roles.active], breathe: v.level === 'full' }, 1))
105 } else buf.push(c)
106 }
107 flush()
108 }
109 if (tail) kids.push(Text({ dimColor: true, children: [tail] }))
110 return Box({ flexDirection: 'row', children: kids })
111}
112
113// ---------- rows ----------
114
115function headerRow(el, v, mark, right) {
116 const { Box, Text, Button } = el
117 const { g, mask } = v
118 const open = Button({ key: 'goal-tasks', label: 'all tasks', hotkey: 'g', plain: true, onPress: () => v.open && v.open() })
119 return Box({
120 flexDirection: 'row',
121 justifyContent: 'space-between',
122 width: '100%',
123 columnGap: 2,
124 children: [
125 Box({
126 flexDirection: 'row',
127 flexShrink: 1,
128 children: [mark, Text({ bold: true, wrap: 'truncate-end', children: [`${label(g)}: ${mask(g.title)}`] })],
129 }),
130 Box({ flexDirection: 'row', columnGap: 2, flexShrink: 0, children: [...right, open] }),
131 ],
132 })
133}
134
135function spinner(el, v, key) {
136 if (v.level === 'off' || v.svg || !v.pulse) return el.Text({ color: v.roles.activeStill, children: [RUNNING] })
137 return v.pulse(key, { kind: 'spin', frames: SPIN.frames, ms: SPIN.ms, colors: [...v.roles.active] }, 1)
138}
139
140function nowRow(el, v, t, more) {
141 const { Box, Text } = el
142 const { g, now, mask, roles } = v
143 const by = byOf(t)
144 const long = runsLong(g, t, now)
145 const right = []
146 if (by) right.push(Text({ color: roles.agent(by), children: [`◆ ${clip(mask(by), 20)}`] }))
147 right.push(Text(long ? { color: roles.heat, children: [` · ${minutes(now - t.startedAt)}`] } : { dimColor: true, children: [` · ${minutes(now - t.startedAt)}`] }))
148 if (more) right.push(Text({ dimColor: true, children: [` +${more} running`] }))
149 return Box({
150 flexDirection: 'row',
151 justifyContent: 'space-between',
152 width: '100%',
153 columnGap: 2,
154 children: [
155 Box({ flexDirection: 'row', flexShrink: 1, columnGap: 1, children: [spinner(el, v, `spin-${t.id}`), Text({ bold: true, wrap: 'truncate-end', children: [`${t.size} ${mask(t.title)}`] })] }),
156 Box({ flexDirection: 'row', flexShrink: 0, children: right }),
157 ],
158 })
159}
160
161function doneRow(el, v, t) {
162 const took = t.doneAt > t.startedAt ? minutes(t.doneAt - t.startedAt) : ''
163 return el.Text({ dimColor: true, wrap: 'truncate-end', children: [`✓ ${t.size} ${v.mask(t.title)}${took ? ' ' + took : ''}`] })
164}
165
166function nextRow(el, v, t) {
167 return el.Text({ dimColor: true, wrap: 'truncate-end', children: [` next ○ ${t.size} ${v.mask(t.title)}`] })
168}
169
170function notableRow(el, v) {
171 const { g, mask, roles } = v
172 if (g.check && g.check.met === false && g.check.reason) {
173 return el.Text({ color: roles.attention, wrap: 'truncate-end', children: [`Goal check: not met yet: ${mask(g.check.reason)}`] })
174 }
175 if (g.interrupted) return el.Text({ color: roles.attention, children: ['interrupted'] })
176 // parked tasks are left out of n, so only an open task of this chat's own waits on the person
177 const p = progress(g)
178 if (isPaused(g) && p.doneN < p.n) return el.Text({ color: roles.attention, children: ['⏸ paused, waiting on you'] })
179 // someone outside the chat has the ball: worth a glance, not the person's attention
180 if (p.parked.length) {
181 const more = p.parked.length > 1 ? ` +${p.parked.length - 1}` : ''
182 return el.Text({ dimColor: true, wrap: 'truncate-end', children: [`${PARKED} parked: ${mask(p.parked[0].note || '')}${more}`] })
183 }
184 return null
185}
186
187// ---------- the band ----------
188
189export function drawBand(el, v) {
190 const { Box, Text } = el
191 const { g, now, roles } = v
192 const p = progress(g)
193 const mark = Text({ color: roles.activeStill, children: ['◎ '] })
194
195 if (g.status === 'running' && !g.planAt) {
196 const wait = Text({ dimColor: true, wrap: 'truncate-end', children: [v.nudged ? 'No plan yet: reminded Claude to plan with Waypoints' : "Waiting for Claude's task plan…"] })
197 return Box({
198 flexDirection: 'column',
199 children: [headerRow(el, v, mark, [Text({ dimColor: true, children: ['planning…'] })]), Box({ flexDirection: 'row', columnGap: 1, children: [spinner(el, v, 'spin-plan'), wait] })],
200 })
201 }
202
203 if (g.status !== 'running') {
204 const finished = g.status === 'met'
205 if (!finished) return Box({ flexDirection: 'column', children: [headerRow(el, v, mark, [Text({ dimColor: true, children: ['stopped'] })])] })
206 if (now - g.endedAt >= FULL_MS) {
207 const ok = Text({ color: roles.ok, children: ['✓ '] })
208 const took = minutes((g.endedAt || now) - g.startedAt)
209 // a parked task outlives the finish here, so green is not read as "all of it is done"
210 const parked = parkedWords(p, v.mask, true)
211 return Box({ flexDirection: 'column', children: [headerRow(el, v, ok, [Text({ dimColor: true, children: [`${p.n} tasks · done in ${took}${parked ? ' · ' + parked : ''}`] })])] })
212 }
213 }
214
215 const tasks = live(g).sort((a, b) => a.id - b.id)
216 const parked = parkedWords(p, v.mask, false)
217 const right = []
218 if (g.status === 'met') {
219 right.push(Text({ color: roles.ok, bold: true, children: [`done ✓ in ${minutes((g.endedAt || now) - g.startedAt)}`] }))
220 if (parked) right.push(Text({ dimColor: true, children: [parked] }))
221 } else {
222 const t = eta(g, now)
223 right.push(
224 Text({
225 children: [
226 Text({ color: roles.activeStill, bold: true, children: [`${p.doneN} of ${p.n} · ${p.pct}%`] }),
227 t ? Text({ dimColor: true, children: [` · ~${minutes(t.ms)}`] }) : null,
228 parked ? Text({ dimColor: true, children: [` · ${parked}`] }) : null,
229 ].filter(Boolean),
230 }),
231 )
232 }
233 const head = headerRow(el, v, mark, right)
234
235 // the route fills the width, less the dim +k when the plan grew (parked tasks count:
236 // they are on the route, and they were in the first plan)
237 const grew = g.firstPlan && tasks.length > g.firstPlan ? ` +${tasks.length - g.firstPlan}` : ''
238 const room = Math.max(8, v.width - grew.length)
239 const { cells } = routeCells(tasks, room, { finished: g.status === 'met' })
240 const parts = { head: [head, routeRow(el, v, cells, grew, tasks)], now: [], next: [], note: [] }
241
242 const active = tasks.filter((t) => t.status === 'active')
243 if (active.length) {
244 active.slice(0, 2).forEach((t, i) => parts.now.push(nowRow(el, v, t, i === 1 ? Math.max(0, active.length - 2) : 0)))
245 } else {
246 const last = lastDone(tasks)
247 if (last) parts.now.push(doneRow(el, v, last))
248 }
249 const pending = tasks.find((t) => t.status === 'pending')
250 if (pending && g.status === 'running') parts.next.push(nextRow(el, v, pending))
251 const note = g.status === 'running' ? notableRow(el, v) : null
252 if (note) parts.note.push(note)
253
254 // short of room, the rows give way in this order: next, notable, now
255 const max = v.maxRows > 0 ? v.maxRows : Infinity
256 let total = parts.head.length + parts.now.length + parts.next.length + parts.note.length
257 for (const k of ['next', 'note', 'now']) {
258 while (total > max && parts[k].length) {
259 parts[k].pop()
260 total -= 1
261 }
262 }
263 return Box({ flexDirection: 'column', children: [...parts.head, ...parts.now, ...parts.next, ...parts.note] })
264}
265
266// ---------- the pane ----------
267
268const BAR_CELLS = 12 // the longest finished task's duration bar
269const NO_CONNECTORS_OVER = 10 // tasks; past this the trail is rows alone
270
271// 30s, 4m, 1h05m: short enough for a column
272export function short(ms) {
273 const s = Math.max(0, Math.round((Number(ms) || 0) / 1000))
274 return s < 60 ? s + 's' : minutes(s * 1000)
275}
276
277// Where the plan last changed course: "revised after #3" ("revised" before any task was done)
278function revisedWords(r) {
279 return r.after ? `revised after #${r.after}` : 'revised'
280}
281
282// The line under the pane's title: the numbers, elapsed, the ETA, growth, parked tasks,
283// the latest revision, goal checks
284export function statsLine(g, p, now, mask = (s) => s) {
285 const parked = parkedWords(p, mask, false)
286 if (g.status === 'met') return `done ✓ in ${minutes((g.endedAt || now) - g.startedAt)}` + (parked ? ` · ${parked}` : '')
287 if (g.status !== 'running') return 'stopped'
288 if (!g.planAt) return 'planning…'
289 const parts = [`${p.doneN} of ${p.n} · ${p.pct}%`, `${minutes(now - g.startedAt)} elapsed`]
290 const t = eta(g, now)
291 if (t) parts.push(`~${minutes(t.ms)} left (≈${clock(t.at)})`)
292 else if (p.doneN < 2) parts.push('ETA after 2 tasks finish')
293 const n = live(g).length
294 if (g.firstPlan && n > g.firstPlan) parts.push(`plan grew ${g.firstPlan} → ${n}`)
295 if (parked) parts.push(parked)
296 const revs = g.revisions || []
297 if (revs.length) parts.push(revisedWords(revs[revs.length - 1]))
298 if (g.checks) parts.push(`${g.checks} goal check${g.checks === 1 ? '' : 's'}`)
299 return parts.join(' · ')
300}
301
302function trailRow(el, v, t, i, count, barOf, titleW) {
303 const { Box, Text } = el
304 const { now, mask, roles } = v
305 const last = i === count - 1
306 // the last task's node is the destination flag, raised once that task is done
307 const glyph =
308 t.status === 'dropped' ? '×' : t.status === 'parked' ? PARKED : t.status === 'active' ? RUNNING : last ? (t.status === 'done' ? FLAG_DONE : FLAG) : t.status === 'done' ? '●' : '○'
309 const color = t.status === 'done' ? roles.done(count > 1 ? i / (count - 1) : 0) : t.status === 'active' ? roles.activeStill : roles.pending
310 const title = clip(mask(t.title), titleW - 3)
311 const body =
312 t.status === 'dropped'
313 ? Text({ dimColor: true, strikethrough: true, children: [`${t.size} ${title}`] })
314 : Text({ bold: t.status === 'active', dimColor: t.status === 'pending', children: [`${t.size} ${title}`] })
315 const tail = []
316 if (t.status === 'done') {
317 const dur = t.doneAt - t.startedAt
318 if (dur > 0) tail.push(Text({ color: roles.done(count > 1 ? i / (count - 1) : 0), children: ['▅'.repeat(barOf(dur))] }), Text({ dimColor: true, children: [' ' + short(dur)] }))
319 } else if (t.status === 'active') {
320 const by = byOf(t)
321 if (by) tail.push(Text({ color: roles.agent(by), children: [`◆ ${clip(mask(by), 20)}`] }), Text({ dimColor: true, children: [' · '] }))
322 tail.push(Text({ color: runsLong(v.g, t, now) ? roles.heat : undefined, dimColor: !runsLong(v.g, t, now), children: [`running ${short(now - t.startedAt)}`] }))
323 } else if (t.status === 'dropped') {
324 tail.push(Text({ dimColor: true, children: [t.note ? `dropped: ${mask(t.note)}` : 'dropped'] }))
325 } else if (t.status === 'parked') {
326 tail.push(Text({ dimColor: true, children: [`parked: ${mask(t.note || '')}`] }))
327 } else if (t.rev) {
328 tail.push(Text({ dimColor: true, children: ['revised'] }))
329 }
330 // the tail is one Text, so its pieces sit side by side with no column gap between them
331 // the running task's star twinkles here as on the route: a Client on the terminal, still elsewhere
332 const twinkle = t.status === 'active' && v.pulse && !v.svg && v.level !== 'off'
333 const node = twinkle ? v.pulse(`pane-node-${t.id}`, { kind: 'node', colors: [...roles.active], breathe: v.level === 'full' }, 1) : Text({ color, children: [glyph] })
334 return Box({ flexDirection: 'row', columnGap: 1, children: [node, Box({ width: titleW, flexShrink: 0, children: [body] }), ...(tail.length ? [Text({ children: tail })] : [])] })
335}
336
337function connectorRow(el, v, above) {
338 return above.status === 'done' ? el.Text({ color: v.roles.done(0.5), children: ['┃'] }) : el.Text({ dimColor: true, children: ['┆'] })
339}
340
341function otherRow(el, v, g) {
342 const { Box, Text } = el
343 const { now, mask, roles } = v
344 const p = progress(g)
345 const name = clip(mask(`${g.label || basename(g.cwd)}: ${g.title}`), Math.max(16, Math.floor(v.width * 0.35)))
346 let tail
347 if (g.status === 'met') tail = 'done ✓'
348 else if (g.status !== 'running') tail = 'stopped'
349 else if (!g.planAt) tail = 'planning'
350 else {
351 const t = eta(g, now)
352 tail = `${p.pct}% · ${t ? '~' + minutes(t.ms) : p.doneN + '/' + p.n}`
353 }
354 const { cells } = miniRoute(g)
355 const route = runs(cells, (c) => colorOf(roles, c)).map((r) => Text({ color: r.color, children: [r.text] }))
356 return Box({
357 flexDirection: 'row',
358 columnGap: 2,
359 children: [Text({ wrap: 'truncate-end', children: [name] }), Box({ flexDirection: 'row', children: route }), Text(g.status === 'met' ? { color: roles.ok, children: [tail] } : { dimColor: true, children: [tail] })],
360 })
361}
362
363// view adds: others (the other chats' goals), sessionId, commandName, settings
364export function drawPane(el, v) {
365 const { Box, Text } = el
366 const { g, mask, roles } = v
367 const rows = []
368 if (g) {
369 const p = progress(g)
370 rows.push(Text({ bold: true, wrap: 'truncate-end', children: [`◎ ${label(g)}: ${mask(g.title)}`] }))
371 rows.push(Text({ dimColor: true, wrap: 'truncate-end', children: [' ' + statsLine(g, p, v.now, mask)] }))
372 rows.push(Text({ children: [' '] }))
373 const tasks = g.tasks.filter((t) => !t.replaced)
374 if (!tasks.length) rows.push(Text({ dimColor: true, children: ['No task plan yet.'] }))
375 const longest = Math.max(1, ...tasks.filter((t) => t.status === 'done').map((t) => t.doneAt - t.startedAt))
376 const barOf = (dur) => Math.max(1, Math.round((BAR_CELLS * dur) / longest))
377 const titleW = Math.max(16, Math.min(48, v.width - 34))
378 tasks.forEach((t, i) => {
379 rows.push(trailRow(el, v, t, i, tasks.length, barOf, titleW))
380 // what a finished task turned out to be, under it: the reason a later step changed
381 if (t.status === 'done' && t.note) rows.push(Text({ dimColor: true, wrap: 'truncate-end', children: [` ${mask(t.note)}`] }))
382 if (i < tasks.length - 1 && tasks.length <= NO_CONNECTORS_OVER) rows.push(connectorRow(el, v, t))
383 })
384 const revs = (g.revisions || []).slice(-3)
385 if (revs.length) {
386 rows.push(Text({ children: [' '] }))
387 for (const r of revs) rows.push(Text({ dimColor: true, wrap: 'truncate-end', children: [`${revisedWords(r)}: ${mask(r.note)}`] }))
388 }
389 if (g.check && g.check.reason) {
390 rows.push(Text({ children: [' '] }))
391 rows.push(Text(g.check.met === false ? { color: roles.attention, children: [`Last goal check: not met yet: ${mask(g.check.reason)}`] } : { dimColor: true, children: [`Last goal check: ${mask(g.check.reason)}`] }))
392 }
393 } else {
394 rows.push(Text({ dimColor: true, children: ['No goal in this chat. Type /goal <what done looks like> to start one.'] }))
395 }
396 const rest = (v.others || []).filter((o) => o.sessionId !== v.sessionId)
397 rows.push(Text({ children: [' '] }))
398 rows.push(Text({ bold: true, children: [`Other chats (${rest.length})`] }))
399 if (!rest.length) rows.push(Text({ dimColor: true, children: ['No other chat has a goal in the last hour.'] }))
400 for (const o of rest.slice(0, 15)) rows.push(otherRow(el, v, o))
401 rows.push(Text({ children: [' '] }))
402 const s = v.settings || {}
403 const c = v.commandName || 'waypoints'
404 const f = v.files
405 if (f && f.count > NOTE_AT) rows.push(Text({ dimColor: true, children: [`${f.count} old files in ${f.dir} · /${c} prune, or delete them while no plan is running`] }))
406 rows.push(Text({ dimColor: true, children: [`auto: ${s.auto ? 'on' : 'off'} · /${c} hide|show · clear (stop the goal) · look & pane: /config → Waypoints`] }))
407 rows.push(Text({ dimColor: true, children: [`waypoints v${VERSION}`] }))
408 return Box({ flexDirection: 'column', children: rows })
409}
410hooks/pane.mjs 35 lines1// When the pane opens and closes by itself. Pure bookkeeping, no engine calls, so the rules
2// the design lists (a person's close sticks, an unload's does not) can be tested: the test
3// engine cannot raise a ui.close with an origin.
4//
5// - It opens on a goal's first plan, unless the setting is off, the person closed the pane
6// during this goal, or it is open already.
7// - A pane the mod opened is the mod's until the person opens it themselves; the mod closes
8// only its own, once the band has gone.
9
10export function createAutoPane() {
11 const closedGoals = new Set() // startedAt of each goal whose pane the person closed
12 let owned = false // the mod opened the pane and the person has not taken it over
13
14 return {
15 canOpen: (mode, goal, isOpen) => mode !== 'off' && !!goal && !isOpen && !closedGoals.has(goal.startedAt),
16 // the mod opened it
17 opened: () => {
18 owned = true
19 },
20 // the person asked for it (/waypoints, g): theirs from now on
21 takenOver: () => {
22 owned = false
23 },
24 // ui.close arrived: only the person's own close is remembered; a plugin's call or the
25 // engine dropping the pane (unload) is not a decision about this goal
26 closed: (origin, goal) => {
27 owned = false
28 if (origin && origin.kind === 'person' && goal) closedGoals.add(goal.startedAt)
29 },
30 // the band has gone: the mod takes back a pane it opened
31 shouldClose: (isOpen, bandUp) => owned && isOpen && !bandUp,
32 owns: () => owned,
33 }
34}
35hooks/prune.mjs 47 lines1// The data folder's upkeep: which chats' files the pane lists, when it says the folder has
2// grown, what /waypoints prune deletes and the delete it runs, and how the folder is named
3// to the person. Pure, so the rules are tested without the engine.
4
5export const OTHERS_MS = 3600000 // other chats' goals shown in /waypoints
6export const NOTE_AT = 100 // past this many files the pane names the folder
7
8export const DEAD_MS = 24 * 3600000 // restore() never loads a file this old again
9export const PRUNE_MAX = 200 // files one /waypoints prune deletes at most
10
11// The file names /waypoints prune deletes, oldest first. items: { name, mtimeMs, goal },
12// goal null for a file that did not parse. Never this chat's file; never a running goal
13// touched within DEAD_MS. A file that did not parse is kept for OTHERS_MS, as another chat
14// may be writing it right now.
15export function pickPrunable(items, { now, self }) {
16 const old = (ms) => now - (ms || 0) > OTHERS_MS
17 return items
18 .filter((it) => {
19 if (it.name === self) return false
20 if (now - (it.mtimeMs || 0) > DEAD_MS) return true
21 if (!it.goal) return old(it.mtimeMs)
22 return it.goal.status !== 'running' && old(it.goal.endedAt || it.mtimeMs)
23 })
24 .sort((a, b) => (a.mtimeMs || 0) - (b.mtimeMs || 0))
25 .slice(0, PRUNE_MAX)
26 .map((it) => it.name)
27}
28
29// What cmd would read as syntax rather than as a path
30const CMD_META = /[&|<>^%!"]/
31
32// The host's delete as an argv, no shell: each path is its own argument. On Windows `del`
33// lives in cmd, which parses the line again, so a path holding a cmd metacharacter deletes
34// nothing (null) and the person is sent to the folder instead.
35export function deleteArgv(win, paths) {
36 if (!win) return ['rm', '-f', '--', ...paths]
37 if (paths.some((p) => CMD_META.test(p))) return null
38 return ['cmd', '/d', '/c', 'del', '/f', '/q', ...paths.map((p) => p.replace(/\//g, '\\'))]
39}
40
41// The folder as the person's system writes it, ending in a separator: a Windows home
42// (with a `\`) turns every `/` into `\`
43export function shownPath(path) {
44 const p = String(path || '')
45 return p.includes('\\') ? p.replace(/\//g, '\\') + '\\' : p + '/'
46}
47hooks/pulse.mjs 57 lines1// The terminal's animations, one `Client` region each, all a fixed width so the band's
2// layout never moves under them. Each runs on the surface's frame clock; the hooks never
3// run per frame. A new key (a new task, a new finish) is a new instance with its own clock.
4//
5// - spin: the running task's spinner, cycling `frames` every `ms`, with `colors` alongside
6// - node: the running task's ✦ (on the route, in the pane): it cycles `colors` and dips to a
7// hollow ✧ once a cycle, like a star twinkling; with `breathe` (full) it also flares ✷.
8// It never goes blank, so the route keeps its shape
9// - sweep: the finish, played once: the sparkle `colors` run along the route (`chars`), then
10// it rests on the solid route in the `done` colours (one per char)
11
12const NODE_MS = 120
13const TWINKLE = 8 // frames a twinkle takes: about once a second
14const SWEEP_MS = 60
15const SWEEP_FRAMES = 25 // 1.5 s
16const TAIL = 4 // cells the sparkle trails behind the head
17
18export default function Pulse(props, surface) {
19 const { Text } = surface.elements
20
21 if (surface.state === undefined) {
22 let frame = 0
23 const ms = props.kind === 'spin' ? props.ms : props.kind === 'node' ? NODE_MS : SWEEP_MS
24 const stop = surface.every(ms, () => {
25 frame += 1
26 // the sweep plays once, then rests
27 if (props.kind === 'sweep' && frame >= SWEEP_FRAMES) stop()
28 surface.setState({ frame })
29 })
30 }
31
32 const frame = surface.state ? surface.state.frame : 0
33
34 if (props.kind === 'spin') {
35 return Text({ color: props.colors[frame % props.colors.length], children: [props.frames[frame % props.frames.length] || ' '] })
36 }
37
38 if (props.kind === 'node') {
39 const at = frame % TWINKLE
40 const glyph = at === TWINKLE - 1 ? '✧' : props.breathe && at === TWINKLE / 2 - 1 ? '✷' : '✦'
41 return Text({ color: props.colors[frame % props.colors.length], children: [glyph] })
42 }
43
44 // sweep: the head runs from the start to past the end; behind it the sparkle, ahead of it the route
45 const chars = [...props.chars]
46 const head = Math.round((frame / SWEEP_FRAMES) * (chars.length + TAIL))
47 const n = props.colors.length
48 const cells = chars.map((ch, i) => ({ ch, color: frame < SWEEP_FRAMES && i <= head && i > head - TAIL ? props.colors[(i + frame) % n] : props.done[i] }))
49 const runs = []
50 for (const c of cells) {
51 const last = runs[runs.length - 1]
52 if (last && last.color === c.color) last.text += c.ch
53 else runs.push({ text: c.ch, color: c.color })
54 }
55 return Text({ children: runs.map((r) => Text({ color: r.color, children: [r.text] })) })
56}
57hooks/route.mjs 107 lines1// The route: the plan drawn as a line of nodes and legs. Pure: it returns cells
2// { ch, role, t } and knows nothing of colour or the engine; draw.mjs colours them
3// and svg.mjs draws the same cells as a picture. Roles: 'done', 'active' (the pulsing
4// node), 'pending' (dim, the running leg and a parked task included) and 'sparkle' (the
5// finish mark).
6
7import { weight } from './plan.mjs'
8
9const MAX_UNIT = 6 // cells per unit of work when there is room to spare
10
11// The destination is a flag, so it reads apart from the task nodes (all circles): open
12// while the plan runs, raised when it is done. Both are one-cell text symbols, not emoji.
13export const FLAG = '⚐'
14export const FLAG_DONE = '⚑'
15// A task waiting on someone outside the chat
16export const PARKED = '⏸'
17// The task running now: a star that twinkles (pulse.mjs), still at animation off
18export const RUNNING = '✦'
19
20function route(tasks) {
21 return tasks.filter((t) => t.status !== 'dropped' && !t.replaced).sort((a, b) => a.id - b.id)
22}
23
24function cell(ch, role) {
25 return { ch, role, t: 0 }
26}
27
28// Cells for a route whose legs are already measured. legs[i] is the length of task i's leg.
29function layout(tasks, legs, finished) {
30 const cells = []
31 // node k is the start of task k (the end of task k-1); a running task lights its start node
32 const lit = (k) => tasks[k] && tasks[k].status === 'active'
33 for (let k = 0; k <= tasks.length; k++) {
34 const last = k === tasks.length
35 if (finished) cells.push(last ? cell(FLAG_DONE, 'sparkle') : cell('●', 'done'))
36 else if (lit(k)) cells.push({ ...cell(RUNNING, 'active'), id: tasks[k].id })
37 else if (last) cells.push(cell(FLAG, tasks[k - 1].status === 'done' ? 'done' : 'pending'))
38 else if (k === 0 || tasks[k - 1].status === 'done') cells.push(cell('●', 'done'))
39 else cells.push(cell('○', 'pending'))
40 if (last) break
41 // a parked leg stays dashed, finished or not, with the pause mark in its middle
42 const parked = tasks[k].status === 'parked'
43 const done = !parked && (finished || tasks[k].status === 'done')
44 const mid = Math.floor((legs[k] - 1) / 2)
45 for (let i = 0; i < legs[k]; i++) cells.push(parked && i === mid ? cell(PARKED, 'pending') : done ? cell('━', 'done') : cell('┄', 'pending'))
46 }
47 const span = Math.max(1, cells.length - 1)
48 cells.forEach((c, i) => (c.t = i / span))
49 return cells
50}
51
52// One glyph per task, no legs, cut with … when the plan is longer than the room
53function dense(tasks, width, finished) {
54 const glyph = (t) =>
55 t.status === 'parked'
56 ? cell(PARKED, 'pending')
57 : finished || t.status === 'done'
58 ? cell('●', 'done')
59 : t.status === 'active'
60 ? { ...cell(RUNNING, 'active'), id: t.id }
61 : cell('○', 'pending')
62 let cells = tasks.map(glyph)
63 if (cells.length > width) cells = [...cells.slice(0, Math.max(0, width - 1)), cell('…', 'pending')]
64 const span = Math.max(1, cells.length - 1)
65 cells.forEach((c, i) => (c.t = i / span))
66 return cells
67}
68
69// The band's route, fitted to `width` cells: legs proportional to S/M/L (at least 2 cells),
70// then 1 cell each, then dense. `finished` draws it all solid (a parked leg aside) with a ⚑ at the end.
71export function routeCells(allTasks, width, { finished = false } = {}) {
72 const tasks = route(allTasks || [])
73 const room = Math.max(1, Math.floor(width))
74 if (!tasks.length) return { cells: [], dense: false }
75 // the start node, then a leg and a node for each task
76 const fits = (legs) => 1 + legs.reduce((a, l) => a + l + 1, 0) <= room
77 for (let unit = MAX_UNIT; unit >= 1; unit--) {
78 const legs = tasks.map((t) => Math.max(2, weight(t) * unit))
79 if (fits(legs)) return { cells: layout(tasks, legs, finished), dense: false }
80 }
81 const ones = tasks.map(() => 1)
82 if (fits(ones)) return { cells: layout(tasks, ones, finished), dense: false }
83 return { cells: dense(tasks, room, finished), dense: true }
84}
85
86// Another chat's route in the pane: a node and a leg per task, or a glyph per task past maxTasks
87export function miniRoute(goal, maxTasks = 12) {
88 const tasks = route(goal.tasks || [])
89 if (!tasks.length) return { cells: [], dense: false }
90 const finished = goal.status === 'met'
91 if (tasks.length > maxTasks) return { cells: dense(tasks, tasks.length, finished), dense: true }
92 return { cells: layout(tasks, tasks.map(() => 1), finished), dense: false }
93}
94
95// Merge neighbouring cells that take the same colour into runs { text, color }, so a
96// tick of drawing is a few Text elements, not one per cell. colorOf(cell) → string | null.
97export function runs(cells, colorOf) {
98 const out = []
99 for (const c of cells) {
100 const color = colorOf(c)
101 const last = out[out.length - 1]
102 if (last && last.color === color) last.text += c.ch
103 else out.push({ text: c.ch, color })
104 }
105 return out
106}
107hooks/svg.mjs 124 lines1// The Desktop route: the same cells the terminal draws (route.mjs), as one SVG document for
2// `Svg`'s `source`. CSS in the document's own <style> does the moving, so the browser runs
3// the loops and the mod draws only when the plan changes.
4//
5// A source that changes restarts its animations (agent-pulse's rule), so nothing here carries
6// the time, the ETA or anything per tick. The finish effects carry their goal's id instead,
7// so they play once for a finish and again only for the next.
8
9import { FLAG, FLAG_DONE, PARKED } from './route.mjs'
10
11const REDUCED = '@media (prefers-reduced-motion: reduce){*{animation:none!important}}'
12const HEIGHT = 14
13const MID = HEIGHT / 2
14const LEGS = '━┄'
15
16const f = (n) => n.toFixed(1)
17
18// A four-point star centred on (x, y)
19function star(x, y, r, attrs) {
20 const k = r * 0.3
21 const pts = [[x, y - r], [x + k, y - k], [x + r, y], [x + k, y + k], [x, y + r], [x - k, y + k], [x - r, y], [x - k, y - k]]
22 return `<polygon points="${pts.map(([a, b]) => f(a) + ',' + f(b)).join(' ')}" ${attrs}/>`
23}
24
25// The width the row takes: the terminal's columns at 8 px each, less the title's share
26export function svgWidth(bodyColumns) {
27 return Math.max(140, Math.min(640, Math.round(bodyColumns * 8 - 40)))
28}
29
30// hex: { done, glow, pending, sparkle[] }; level: off | subtle | full; finishId: set once the
31// goal has finished, so its one-shot effects have an address
32export function routeSvg(cells, { hex, level, width, finishId = '' }) {
33 const unit = width / Math.max(1, cells.length)
34 const xs = (i) => (i + 0.5) * unit
35 const nodes = []
36 cells.forEach((c, i) => {
37 if (!LEGS.includes(c.ch)) nodes.push({ c, i })
38 })
39 const dense = nodes.length === cells.length // one glyph per task: no legs, smaller nodes
40 const r = dense ? 2.2 : 3.5
41 const live = level !== 'off'
42 const body = []
43 const light = []
44
45 // legs first, so the nodes sit on top
46 for (let k = 0; k + 1 < nodes.length; k++) {
47 const a = nodes[k]
48 const b = nodes[k + 1]
49 if (b.i - a.i < 2) continue
50 const x1 = xs(a.i) + r
51 const x2 = xs(b.i) - r
52 const done = cells[a.i + 1].ch === '━'
53 body.push(
54 done
55 ? `<line x1="${f(x1)}" y1="${MID}" x2="${f(x2)}" y2="${MID}" stroke="url(#g)" stroke-width="3" stroke-linecap="round"/>`
56 : `<line x1="${f(x1)}" y1="${MID}" x2="${f(x2)}" y2="${MID}" stroke="${hex.pending}" stroke-width="2" stroke-dasharray="3 3" opacity=".6"/>`,
57 )
58 // at full, a small light travels along the running leg from its start node
59 if (level === 'full' && a.c.role === 'active') {
60 light.push(`<circle class="go" cx="${f(x1)}" cy="${MID}" r="1.8" fill="${hex.glow}" style="--d:${f(Math.max(0, x2 - x1))}px"/>`)
61 }
62 }
63
64 for (const { c, i } of nodes) {
65 const x = xs(i)
66 if (c.role === 'active') {
67 // the running task: a star in its glow, twinkling at subtle and above
68 body.push(`<circle${live ? ' class="halo"' : ''} cx="${f(x)}" cy="${MID}" r="${f(r + 2.5)}" fill="${hex.glow}" opacity=".3"/>`)
69 body.push(star(x, MID, r + 2, `${live ? 'class="run" ' : ''}fill="${hex.done}"`))
70 } else if (c.ch === FLAG || c.ch === FLAG_DONE) {
71 // the destination: a pole and a pennant, outlined until the plan finishes
72 const paint = c.role === 'sparkle' ? hex.sparkle[0] : c.role === 'done' ? 'url(#g)' : hex.pending
73 const dim = c.role === 'pending' ? ' opacity=".7"' : ''
74 const px = x - 3
75 body.push(`<line x1="${f(px)}" y1="1" x2="${f(px)}" y2="13" stroke="${paint}" stroke-width="1.5" stroke-linecap="round"${dim}/>`)
76 const pennant = `points="${f(px)},1.5 ${f(px + 8)},4.5 ${f(px)},7.5"`
77 body.push(c.ch === FLAG_DONE ? `<polygon ${pennant} fill="${paint}"/>` : `<polygon ${pennant} fill="none" stroke="${paint}" stroke-width="1.2" stroke-linejoin="round"${dim}/>`)
78 } else if (c.ch === PARKED) {
79 // a parked task: two short bars on its dashed leg
80 body.push(`<rect x="${f(x - 2.2)}" y="${MID - 3.5}" width="1.6" height="7" rx=".5" fill="${hex.pending}"/><rect x="${f(x + 0.6)}" y="${MID - 3.5}" width="1.6" height="7" rx=".5" fill="${hex.pending}"/>`)
81 } else if (c.role === 'done') {
82 body.push(`<circle cx="${f(x)}" cy="${MID}" r="${f(r)}" fill="url(#g)"/>`)
83 } else {
84 body.push(`<circle cx="${f(x)}" cy="${MID}" r="${f(r - 0.5)}" fill="none" stroke="${hex.pending}" stroke-width="1.5" opacity=".7"/>`)
85 }
86 }
87
88 // the finish, at full: a light sweeps the route once, three stars twinkle at the end
89 const effects = []
90 if (finishId && level === 'full') {
91 const end = nodes.length ? xs(nodes[nodes.length - 1].i) : width
92 effects.push(`<circle class="sweep" cx="0" cy="${MID}" r="5" fill="${hex.sparkle[3] || hex.sparkle[0]}" style="--d:${f(width)}px"/>`)
93 ;[[-7, -3.5, 0], [6, -4, 0.35], [-1, 4.5, 0.7]].forEach(([dx, dy, delay], n) => {
94 effects.push(star(end + dx, MID + dy, 2.4, `class="tw" fill="${hex.sparkle[n + 1] || hex.sparkle[0]}" style="animation-delay:${delay}s"`))
95 })
96 }
97
98 const css = []
99 if (live) {
100 css.push('.halo{animation:halo 1.4s ease-in-out infinite}@keyframes halo{0%,100%{opacity:.15}50%{opacity:.6}}')
101 css.push('.run{transform-box:fill-box;transform-origin:center;animation:run 1s ease-in-out infinite}@keyframes run{0%,70%,100%{transform:scale(1);opacity:1}85%{transform:scale(.6);opacity:.55}}')
102 }
103 if (level === 'full') {
104 css.push('.go{animation:go 1.8s linear infinite;opacity:0}@keyframes go{0%{transform:translateX(0);opacity:0}20%,80%{opacity:1}100%{transform:translateX(var(--d));opacity:0}}')
105 css.push('.sweep{opacity:0;animation:sweep 1.5s ease-in forwards}@keyframes sweep{0%{transform:translateX(0);opacity:0}15%,85%{opacity:.9}100%{transform:translateX(var(--d));opacity:0}}')
106 css.push('.tw{opacity:0;transform-box:fill-box;transform-origin:center;animation:tw 1.2s ease-out 1 forwards}@keyframes tw{0%{transform:scale(0);opacity:0}40%{transform:scale(1.2);opacity:1}100%{transform:scale(1);opacity:.9}}')
107 }
108
109 return (
110 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${HEIGHT}" viewBox="0 0 ${width} ${HEIGHT}">` +
111 `<style>${css.join('')}${REDUCED}</style>` +
112 `<defs><linearGradient id="g" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="${width}" y2="0"><stop offset="0" stop-color="${hex.done}"/><stop offset="1" stop-color="${hex.glow}"/></linearGradient></defs>` +
113 body.join('') +
114 light.join('') +
115 (effects.length ? `<g id="fin-${finishId}">${effects.join('')}</g>` : '') +
116 '</svg>'
117 )
118}
119
120// What a screen reader hears of the route: numbers only, so there is no title to mask
121export function routeAlt(done, total, running, parked = 0) {
122 return `${done} of ${total} tasks done` + (running.length ? `, task${running.length > 1 ? 's' : ''} ${running.join(' and ')} running` : '') + (parked ? `, ${parked} parked` : '')
123}
124hooks/version.mjs 6 lines1// The host gives a hooks module no way to read its own manifest (not through
2// "node:fs", not as a JSON import), so the pane's last row carries a copy.
3// Bump it with plugin.json on every release; CI fails a pull request where the
4// two differ (.github/scripts/check-versions.mjs).
5export const VERSION = '2.2.1'
6