A Claude Code mod: a notification pane for your Datadog queries and GitHub pull requests.

on-my-watch is a Claude Code mod: a notification pane for your Datadog queries and GitHub pull requests. It runs your queries, compares each value with the line you set, and tells you when one crosses it.
AI tools have made writing and shipping code faster than reviewing and watching it. A change can reach a shared environment and slowly make things worse, and the person who made it may not find out for a while.
Monitors are the right tool for failures you already know about. Each one has an owner, a threshold, alert routing and an on-call rotation behind it, which is why adding one takes care. Before that point there is usually a simpler question: what does this service look like right now, after my change? on-my-watch is for that question. It runs your own Datadog queries on a schedule, in a pane next to your work, and tells only you what it finds.
It does not replace monitoring:
pup sign-in, so it sees only what you can already see.architecture.md explains how it works, why it costs no Claude tokens, and how Claude can help you set it up.
pup, the Datadog CLI, signed in with pup auth login. It runs your queries.gh, the GitHub CLI, signed in with gh auth login. It lists pull requests, CI status and reviews.notify-send for notifications.When a tool is missing or signed out, the pane says what to do.
/plugin marketplace add imhalawa/on-my-watch /plugin install on-my-watch@on-my-watch ``/on-my-watch:configure and answer its questions. It writes ~/.on-my-watch/config.json for you. To write it by hand instead, copy config.example.json there./watch, then press ↻ in each pane.python3 <plugin folder>/bin/schedule.py install. It uses launchd on macOS, your crontab on Linux and Task Scheduler on Windows. uninstall removes it.Checkout · Production. Its rows are grouped by service.bots.config.json: Open in editor opens it, and its $schema gives your editor checks and completion. Or ask Claude to change them.config.json.bak.r refreshes, h opens History.pup or gh needs you.The config is a list of GitHub repositories. Each one holds the queries that watch its services:
config.json
├── repos: one per GitHub repository (owner/name)
│ ├── name: what the panes show, such as Checkout
│ ├── pull requests: every PR on the repository, by any author
│ └── queries
│ ├── service: groups the rows inside a card
│ ├── env: one card per repository and environment
│ └── label, query, alert line
└── environments: optional groups for Watch's filter
repos lists each repository by its GitHub owner/name, with the name you want to see and its queries. Each query names the service it watches. List a repository once, and within it use each combination of service, env and label once:
"repos": [
{
"repo": "acme/checkout-api",
"name": "Checkout",
"queries": [
{ "service": "API", "label": "Errors", "env": "Production", "priority": 1, "source": "logs",
"query": "service:checkout-api env:production status:error",
"alert": { "above": 20 } }
]
}
]
That entry reads Checkout › API: Production - Errors. Pull requests show once per repository, under its name. A repository with "watch_only": true is left out of Pull requests. A repository with no queries shows only there.
| Query field | Meaning |
|---|---|
service | The service the query watches, as you want to see it. The pane groups rows by it. |
label | What the query watches, such as Errors, Warnings or a name of your own |
env | The environment the entry shows under. The query itself decides which data it reads. |
priority | 1 (High, red), 2 (Medium, yellow) or 3 (Low, grey). Cards and rows sort by it. |
source | logs, traces or metrics |
query | The full Datadog query, run exactly as written |
alert | above, below or both. Every condition given must hold. |
schedule | Optional cron line for this query alone. Without it, the top-level schedule applies. |
compute | For logs and traces. Default count, for example percentile(@duration, 95) |
reduce | For metrics. Default avg; also sum, max, min, last |
notify | false keeps the entry in the pane only |
investigate | Optional prompt and problem templates for this query's Investigate. Each one overrides the general one in Investigate prompt; one left out uses the general one. |
Top-level fields:
| Field | Meaning |
|---|---|
site | Your Datadog site, such as datadoghq.com |
schedule | Cron line for the checks |
pullRequestsSchedule | Optional cron line for pull requests. Without it, schedule applies. |
window | How far back each check looks, for example 1h |
repos | The repositories and their queries, described above |
environments | Optional groups that Watch's filter shows as one, such as { "Production": ["Production", "Canary"] }. Each query keeps its own env. Watch's filter, the query form's Environment list and the Config counts all use it. |
panes | { "watch": false } or { "pull_requests": false } turns a pane off |
historyDays | How many days of runs to keep. 30 by default. |
bots | The GitHub usernames that Hide bots hides. ["dependabot"] by default. |
notify | { "watch": true, "pull_requests": true }. Sends one desktop notification for each new entry over its line, each open pull request whose CI turns red, and each time pup or gh needs you. It works from the schedule with Claude Code closed. |
investigate | { "session": "current" } investigates in the session you are in instead of a new one. { "skill": "triage" } runs your own skill with the problem, as /triage <problem>. prompt and problem set the message it sends. See Investigate prompt. |
config.schema.json describes all of it. The checker refuses a config that breaks the schema and says where, so an unknown field, a missing label or a priority of 0 is caught before anything runs. python3 bin/on_my_watch.py validate checks yours. Point $schema at the file and your editor checks it as you type. See config.example.json for a full example.
Investigate sends Claude a message about the problems it was pressed on. investigate.prompt sets the whole message. investigate.problem sets one line, which is repeated once per problem into {{problems}}. Each template left out sends today's text.
"investigate": {
"prompt": "Investigate {{name}} ({{repo}}) in {{env}}, from {{from}} to {{to}}.\n{{problems}}\nQuery Datadog with pup first. Do not write tests.",
"problem": "- {{priority}} {{service}}: {{label}} is {{value}} ({{alert}}). {{source}} query `{{query}}`. Datadog: {{link}}"
}
A query can have its own investigate with prompt, problem or both. Each one it sets replaces the general one for that query's problem. The one it leaves out comes from the general investigate.
| Variable | In | Value |
|---|---|---|
{{name}} | both | The repository's name from the config, such as Checkout |
{{repo}} | both | The repository, as owner/name |
{{env}} | both | The query's env |
{{window}} | prompt | The config's window, such as 1h |
{{checkedAt}} | prompt | When the check ran |
{{from}}, {{to}} | prompt | The start and end of the window the check counted |
{{problems}} | prompt | Every problem line, one per line |
{{priority}} | problem | P1, P2 or P3 |
{{service}} | problem | The query's service |
{{label}} | problem | The query's label |
{{value}} | problem | The number the check found, such as 666 |
{{alert}} | problem | The alert line it crossed, such as above 19 |
{{source}} | problem | logs, traces or metrics |
{{query}} | problem | The query, as written in the config |
{{link}} | problem | The query in Datadog, opened on the checked window |
In prompt, {{name}}, {{repo}} and {{env}} are the first problem's. A variable with no value is filled with nothing. A name that is not in the table, such as {{lnk}}, stays as written, so a typo shows in the message.
Everything lives in ~/.on-my-watch/: config.json, every run in history.db (SQLite), and run.log from scheduled runs.
python3 -B -m unittest discover -s tests
claude plugin test .
claude plugin validate .
hooks/register.tsx 1571 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Part, Problem, PullRequest, Result } from '../types'
5
6// Two panes, so the app can place and size them apart; each can be turned off in the config.
7const PANES = {
8 watch: { id: 'on-my-watch', title: 'Watch' },
9 pull_requests: { id: 'on-my-watch-prs', title: 'Pull requests' },
10} as const
11// Config and the schedule, for both parts: a pane of its own, always open beside the others.
12const CONFIG_PANE = { id: 'on-my-watch-config', title: 'Config' } as const
13// A desktop app can start with a short PATH; these are where pup and gh usually live on macOS and Linux.
14const TOOL_DIRS = '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin'
15
16const result = atom({ plugin: 'on-my-watch', key: 'result' } as const, null)
17const running = atom({ plugin: 'on-my-watch', key: 'running' } as const, null)
18const runningSince = atom({ plugin: 'on-my-watch', key: 'runningSince' } as const, null)
19const span = atom({ plugin: 'on-my-watch', key: 'span' } as const, '3d')
20const hideBots = atom({ plugin: 'on-my-watch', key: 'hideBots' } as const, false)
21const prState = atom({ plugin: 'on-my-watch', key: 'prState' } as const, 'open')
22// Long lists draw a page at a time: the pull requests' page, and the open repository's page of queries.
23const prPage = atom({ plugin: 'on-my-watch', key: 'prPage' } as const, 1)
24// Watch narrows to one priority, name or environment; empty is all. The choices come from the config's queries.
25const priorityFilter = atom({ plugin: 'on-my-watch', key: 'priorityFilter' } as const, '')
26const nameFilter = atom({ plugin: 'on-my-watch', key: 'nameFilter' } as const, '')
27const envFilter = atom({ plugin: 'on-my-watch', key: 'envFilter' } as const, '')
28const choices = atom({ plugin: 'on-my-watch', key: 'choices' } as const, { names: [], envs: [], envGroups: {}, priorities: [], bots: ['dependabot'] })
29const quietEntries = atom({ plugin: 'on-my-watch', key: 'quietEntries' } as const, [])
30const groupPrs = atom({ plugin: 'on-my-watch', key: 'groupPrs' } as const, true)
31const watchView = atom({ plugin: 'on-my-watch', key: 'watchView' } as const, 'latest')
32const runs = atom({ plugin: 'on-my-watch', key: 'runs' } as const, [])
33const archived = atom({ plugin: 'on-my-watch', key: 'archived' } as const, null)
34const watchWindow = atom({ plugin: 'on-my-watch', key: 'watchWindow' } as const, '')
35const avatars = atom({ plugin: 'on-my-watch', key: 'avatars' } as const, {})
36const muted = atom({ plugin: 'on-my-watch', key: 'muted' } as const, false)
37const scheduleState = atom({ plugin: 'on-my-watch', key: 'scheduleState' } as const, null)
38const customFor = atom({ plugin: 'on-my-watch', key: 'customFor' } as const, null)
39const cronDraft = atom({ plugin: 'on-my-watch', key: 'cronDraft' } as const, '')
40// The config as the Config pane edits it, when it was read and what the schema says of it.
41const configDoc = atom({ plugin: 'on-my-watch', key: 'configDoc' } as const, null)
42const configReadAt = atom({ plugin: 'on-my-watch', key: 'configReadAt' } as const, '')
43const configProblems = atom({ plugin: 'on-my-watch', key: 'configProblems' } as const, [])
44const configError = atom({ plugin: 'on-my-watch', key: 'configError' } as const, '')
45// Every change the Config pane makes lands here first; Save changes writes it, Discard and Reload drop it.
46const configDraft = atom({ plugin: 'on-my-watch', key: 'configDraft' } as const, null)
47// The environment groups as typed, [name, members] row by row, and the row being added; null until one changes.
48const envRows = atom({ plugin: 'on-my-watch', key: 'envRows' } as const, null)
49const envDraft = atom({ plugin: 'on-my-watch', key: 'envDraft' } as const, { name: '', members: '' })
50
51const settingsDraft = atom({ plugin: 'on-my-watch', key: 'settingsDraft' } as const, null)
52// The schedule builder: how often, between which hours (or at which), on which days. It reads a cron line back
53// into those parts, and makes one from them; a line it can't make is Custom.
54type Build = { every: '15m' | '30m' | '1h' | 'once' | 'twice'; allDay: boolean; from: number; to: number; at: number[]; days: number[] }
55const EVERY = [
56 { value: '15m', label: 'Every 15 minutes' },
57 { value: '30m', label: 'Every 30 minutes' },
58 { value: '1h', label: 'Every hour' },
59 { value: 'once', label: 'Once a day' },
60 { value: 'twice', label: 'Twice a day' },
61] as const
62const STEP: Record<string, number> = { '15m': 15, '30m': 30, '1h': 60 }
63// Work hours are 09:00–18:00, Monday to Friday, as the example config's schedule.
64const WORK: Build = { every: '1h', allDay: false, from: 9, to: 18, at: [9, 13], days: [1, 2, 3, 4, 5] }
65const WEEK = [[1, 'Mon', 'Monday'], [2, 'Tue', 'Tuesday'], [3, 'Wed', 'Wednesday'], [4, 'Thu', 'Thursday'], [5, 'Fri', 'Friday'],
66 [6, 'Sat', 'Saturday'], [0, 'Sun', 'Sunday']] as const
67const HOURS = Array.from({ length: 24 }, (_, h) => ({ value: String(h), label: `${String(h).padStart(2, '0')}:00` }))
68const hh = (h: number, m = 0) => `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
69
70function readDays(field: string): number[] | null {
71 if (field === '*') return [0, 1, 2, 3, 4, 5, 6]
72 const days = new Set<number>()
73 for (const part of field.split(',')) {
74 const m = /^([0-7])(?:-([0-7]))?$/.exec(part)
75 if (!m) return null
76 for (let d = Number(m[1]); d <= Number(m[2] ?? m[1]); d++) days.add(d % 7)
77 }
78 return [...days].sort((a, b) => a - b)
79}
80function daysField(days: number[]) {
81 if (days.length === 7) return '*'
82 const runs: number[][] = []
83 for (const d of [...days].sort((a, b) => a - b)) {
84 const last = runs.at(-1)
85 if (last && d === last.at(-1)! + 1) last.push(d)
86 else runs.push([d])
87 }
88 return runs.map(r => (r.length > 2 ? `${r[0]}-${r.at(-1)}` : r.join(','))).join(',')
89}
90function readBuild(cron: string): Build | null {
91 const f = cron.trim().split(/\s+/)
92 if (f.length !== 5 || f[2] !== '*' || f[3] !== '*') return null
93 const [minute, hour] = f as [string, string]
94 const days = readDays(f[4]!)
95 const every = ({ '*/15': '15m', '*/30': '30m', 0: '1h' } as Record<string, Build['every']>)[minute]
96 if (!days || !every) return null
97 if (hour === '*') return { ...WORK, every, allDay: true, days }
98 const range = /^(\d{1,2})-(\d{1,2})$/.exec(hour)
99 if (range && Number(range[1]) <= Number(range[2]) && Number(range[2]) < 24) return { ...WORK, every, from: Number(range[1]), to: Number(range[2]), days }
100 const at = hour.split(',')
101 if (every === '1h' && at.length <= 2 && at.every(h => /^\d{1,2}$/.test(h) && Number(h) < 24))
102 return { ...WORK, every: at.length === 1 ? 'once' : 'twice', at: at.map(Number), days }
103 return null
104}
105function cronOfBuild(b: Build) {
106 const daily = b.every === 'once' || b.every === 'twice'
107 const minute = ({ '15m': '*/15', '30m': '*/30' } as Record<string, string>)[b.every] ?? '0'
108 const hour = daily ? b.at.slice(0, b.every === 'once' ? 1 : 2).join(',') : b.allDay ? '*' : `${b.from}-${b.to}`
109 return `${minute} ${hour} * * ${daysField(b.days)}`
110}
111// What a build runs, in words: `Every 30 minutes from 09:00 to 18:30, Monday to Friday`.
112function describeBuild(b: Build) {
113 const order = WEEK.filter(([d]) => b.days.includes(d))
114 const at = WEEK.map(([d]) => d as number)
115 const unbroken = order.length > 1 && order.every(([d], i) => i === 0 || at.indexOf(d) === at.indexOf(order[i - 1]![0]) + 1)
116 const days = b.days.length === 7 ? 'every day'
117 : b.days.length === 2 && b.days.includes(0) && b.days.includes(6) ? 'weekends'
118 : unbroken && order.length > 2 ? `${order[0]![2]} to ${order.at(-1)![2]}`
119 : order.map(([, , name]) => name).join(', ')
120 const step = STEP[b.every]
121 const when = b.every === 'once' ? `Once a day at ${hh(b.at[0]!)}`
122 : b.every === 'twice' ? `Twice a day at ${hh(b.at[0]!)} and ${hh(b.at[1]!)}`
123 : `${EVERY.find(e => e.value === b.every)!.label}${b.allDay ? ', all day' : ` from ${hh(b.from)} to ${hh(b.to, 60 - step!)}`}`
124 return `${when}, ${days}`
125}
126// The width, in cells, of every label in the Config pane, so the fields after them line up.
127const LABEL_WIDTH = 18
128// Fields for short values, in cells: `15m`, `30`.
129const SHORT_FIELDS: Record<string, number> = { window: 12, historyDays: 8 }
130
131// A query's priority: 1, 2 or 3, the most urgent first.
132const PRIORITY_NAMES = ['High', 'Medium', 'Low']
133// The mod's own accent: section icons, chevrons and the new mark, in Claude's orange.
134const ACCENT = 'accent' as const
135// The rows whose field the schema requires, by row key: their labels carry a red asterisk.
136const REQUIRED = new Set(['site', 'window', 'field:repo', 'field:name',
137 'field:service', 'field:label', 'field:env', 'field:priority', 'field:source', 'field:query', 'field:line'])
138// The widths, in cells, of History's columns: when and the window, then each count.
139const HISTORY_WHEN_WIDTH = 22
140const HISTORY_COUNT_WIDTH = 7
141// The width, in cells, of an environment group's name field, so the members after them line up.
142const ENV_GROUP_WIDTH = 18
143const CUSTOM = { value: 'custom', label: 'Custom…' }
144// A pull request's state as GitHub gives it: Closed is closed without merging.
145const STATES = [
146 { value: 'open', label: 'Open' },
147 { value: 'merged', label: 'Merged' },
148 { value: 'closed', label: 'Closed' },
149] as const
150
151// A box drawn before a label: the engine has no checkbox element.
152function checkbox(ui: any, look: Look, key: string, label: string, checked: boolean, toggle: () => unknown, hotkey?: string) {
153 return <ui.Button key={key} plain hotkey={hotkey ? look.hotkey(hotkey) : undefined} label={`${checked ? '☑' : '☐'} ${label}`} onPress={toggle} />
154}
155
156// One change to the draft: `set` a JSON value at a dotted path (`+` adds to a list) or `delete` it. Nothing is written.
157function applyEdit(o: any, [action, path, json]: string[]) {
158 const keys = path!.split('.')
159 const last = keys.pop()!
160 let node = o
161 for (const k of keys) node = Array.isArray(node) ? node[Number(k)] : (node[k] ??= {})
162 if (action === 'delete') {
163 if (Array.isArray(node)) node.splice(Number(last), 1)
164 else delete node[last]
165 } else if (last === '+') node.push(JSON.parse(json!))
166 else node[Array.isArray(node) ? Number(last) : last] = JSON.parse(json!)
167}
168async function saveConfig($: any, args: string[]) {
169 const base = (await read($, configDraft)) ?? (await read($, configDoc))
170 const next = JSON.parse(JSON.stringify(base ?? {}))
171 applyEdit(next, args)
172 await update($, configDraft, () => next)
173}
174// Save changes: what is still being typed joins the draft, then the checker checks the whole of it and writes it,
175// keeping the file it replaces.
176async function saveAll($: any) {
177 await saveSettings($, (await read($, settingsDraft)) ?? {})
178 const groups = await read($, envRows)
179 if (groups) {
180 const names = groups.map(([name]) => name.trim())
181 if (names.some(n => !n) || new Set(names).size !== names.length) return update($, configError, () => 'Each environment group needs a name of its own.')
182 const object = Object.fromEntries(groups.map(([name, members]) => [name.trim(), list(members)]))
183 await saveConfig($, groups.length ? ['set', 'environments', JSON.stringify(object)] : ['delete', 'environments'])
184 }
185 const typed = await read($, customFor)
186 if (typed !== null) await setSchedule($, await read($, cronDraft), typed)
187 const draft = await read($, configDraft)
188 if (!draft) return
189 const done = await checker($, ['config', 'replace'], JSON.stringify(draft))
190 const refused = done.exitCode === 0 ? '' : done.stderr.trim() || 'the config was not saved'
191 await update($, configError, () => refused)
192 if (refused) return
193 await discard($)
194 await readPanes($)
195 await scheduleCommand($, ['status'])
196}
197async function discard($: any) {
198 for (const clear of [() => update($, configDraft, () => null), () => update($, settingsDraft, () => null),
199 () => update($, customFor, () => null), () => update($, envRows, () => null)]) await clear()
200}
201
202// The settings the Config pane edits: what a field shows of the config, and the JSON it saves (null takes it out).
203const list = (v: string) => v.split(',').map(x => x.trim()).filter(Boolean)
204const listJson = (v: string) => (list(v).length ? JSON.stringify(list(v)) : null)
205const SETTINGS = [
206 { path: 'site', section: 'watch', label: 'Datadog site', hint: 'datadoghq.com', of: (d: any) => d.site ?? '', json: (v: string) => JSON.stringify(v) },
207 { path: 'window', section: 'watch', label: 'Window', hint: '1h', of: (d: any) => d.window ?? '', json: (v: string) => JSON.stringify(v) },
208 { path: 'historyDays', section: 'watch', label: 'History rotation', unit: 'days', hint: '30', of: (d: any) => (d.historyDays == null ? '' : String(d.historyDays)), json: (v: string) => v || null },
209 { path: 'bots', section: 'prs', label: 'Bots', hint: 'dependabot, renovate', of: (d: any) => (d.bots ?? ['dependabot']).join(', '), json: listJson },
210] as const
211// The settings as typed, each changed one put into the draft.
212async function saveSettings($: any, typed: Record<string, string>) {
213 for (const f of SETTINGS) {
214 const doc = ((await read($, configDraft)) ?? (await read($, configDoc))) as any
215 const v = typed[f.path]?.trim()
216 if (v === undefined || v === f.of(doc ?? {})) continue
217 const json = f.json(v)
218 await saveConfig($, json === null ? ['delete', f.path] : ['set', f.path, json])
219 }
220 await update($, settingsDraft, () => null)
221}
222
223// The Config pane: the file and what it holds, settings, environments, panes and notifications, and the schedule.
224async function configView($: any, ui: any, look: Look, picker: ReturnType<typeof pickerFor>, narrow = false) {
225 const { Box, Text, Button } = ui
226 const canType = 'Input' in ui
227 const dirtyDraft = await read($, configDraft)
228 const doc = (dirtyDraft ?? (await read($, configDoc))) as any
229 const readAt = await read($, configReadAt)
230 const problems = await read($, configProblems)
231 const saveError = await read($, configError)
232 const state = await read($, scheduleState)
233 const custom = await read($, customFor)
234 const draft = await read($, cronDraft)
235 const changes = await read($, settingsDraft)
236 const when = (iso: string) => (day(iso) === 'Today' ? time(iso) : `${day(iso)} ${time(iso)}`)
237 // The environments the config's queries already use, for the query form's dropdown.
238 const environments = environmentList(doc)
239
240 // A section as a card: its icon and name, its actions at the right, then what it holds.
241 const card = (key: string, icon: IconName, text: string, right: unknown, ...body: unknown[]) => (
242 <Box key={`card:${key}`} flexDirection="column" rowGap={1} borderStyle="round" borderColor="subtle" paddingX={1}>
243 <Box key={`h:${key}`} flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={2} flexWrap="wrap">
244 {/* The icon is decoration here: the terminal writes no word for it, the title says it. */}
245 <Box flexDirection="row" alignItems="center" columnGap={1}>{look.icon(icon, ACCENT, text, '')}<Text bold>{text}</Text></Box>
246 {right}
247 </Box>
248 {body}
249 </Box>
250 )
251 // A group's name inside a form, dim, above the fields it heads.
252 const group = (key: string, text: string) => <Box key={`group:${key}`} marginTop={1}><Text dimColor>{text}</Text></Box>
253 // A labelled line: every label takes the same width, so the fields after them line up.
254 // Narrow: the label above its field, so the field and its hint get the whole width.
255 const row = (key: string, label: string, ...control: unknown[]) => (
256 <Box key={`row:${key}`} flexDirection={narrow ? 'column' : 'row'} alignItems={narrow ? 'stretch' : 'center'} columnGap={1}>
257 <Box key={`label:${key}`} width={narrow ? undefined : LABEL_WIDTH} flexShrink={0} flexDirection="row">
258 <Text dimColor>{label}</Text>{REQUIRED.has(key) && <Text key={`required:${key}`} color="error"> *</Text>}
259 </Box>
260 <Box flexDirection="row" flexGrow={1} minWidth={0} columnGap={2} alignItems="center" flexWrap="wrap">{control}</Box>
261 </Box>
262 )
263
264 // The schedule builder for one entry ('' is every query): each change saves the cron line it makes.
265 const cronPicker = (entry: string, cron: string, extra: { value: string; label: string }[] = [], same = false) => {
266 // `same`: it has no line of its own and follows Watch's, so only the dropdown shows, on Same as Watch.
267 if (same) {
268 const from = readBuild(cron) ?? WORK
269 return (
270 <Box key={`picker:${entry}`} flexDirection="row">
271 {picker(`cron:${entry}`, '', [...extra, ...EVERY, CUSTOM], 'default', (v: string) => (v === CUSTOM.value
272 ? Promise.all([update($, customFor, () => entry), update($, cronDraft, () => cron)])
273 : v === 'default' ? null : setSchedule($, cronOfBuild({ ...from, every: v as Build['every'] }), entry)))}
274 </Box>
275 )
276 }
277 const built = custom === entry ? null : readBuild(cron)
278 const save = (b: Build) => setSchedule($, cronOfBuild(b), entry)
279 const base = built ?? WORK
280 const pick = (v: string) => {
281 if (v === CUSTOM.value) return Promise.all([update($, customFor, () => entry), update($, cronDraft, () => cron)])
282 if (v === 'default') return setSchedule($, v, entry)
283 return save({ ...base, every: v as Build['every'] })
284 }
285 const hotkey = (k: string) => (entry ? '' : k)
286 const daily = built && (built.every === 'once' || built.every === 'twice')
287 return (
288 <Box key={`picker:${entry}`} flexDirection="column" rowGap={1}>
289 <Box flexDirection="row" columnGap={2} alignItems="center" flexWrap="wrap">
290 {picker(`cron:${entry}`, hotkey('c'), [...extra, ...EVERY, CUSTOM], built ? built.every : CUSTOM.value, pick)}
291 {built && !daily && !built.allDay && [
292 <Text key="from-word" dimColor>from</Text>,
293 picker(`from:${entry}`, '', HOURS, String(built.from), v => save({ ...built, from: Number(v), to: Math.max(built.to, Number(v)) })),
294 <Text key="to-word" dimColor>to</Text>,
295 picker(`to:${entry}`, '', HOURS, String(built.to), v => save({ ...built, to: Number(v), from: Math.min(built.from, Number(v)) })),
296 ]}
297 {built && !daily && checkbox(ui, look, `allday:${entry}`, 'All day', built.allDay, () => save({ ...built, allDay: !built.allDay }))}
298 {daily && [
299 <Text key="at-word" dimColor>at</Text>,
300 picker(`at1:${entry}`, '', HOURS, String(built.at[0]), v => save({ ...built, at: [Number(v), built.at[1] ?? 13] })),
301 ...(built.every === 'twice' ? [<Text key="and-word" dimColor>and</Text>,
302 picker(`at2:${entry}`, '', HOURS, String(built.at[1] ?? 13), v => save({ ...built, at: [built.at[0]!, Number(v)] }))] : []),
303 ]}
304 </Box>
305 {built && (
306 <Box flexDirection="row" columnGap={1} alignItems="center" flexWrap="wrap">
307 {WEEK.map(([d, short]) => checkbox(ui, look, `day:${d}:${entry}`, short, built.days.includes(d), () => {
308 const days = built.days.includes(d) ? built.days.filter(x => x !== d) : [...built.days, d]
309 return days.length ? save({ ...built, days }) : null // a schedule needs one day at least
310 }))}
311 <Button key={`work:${entry}`} variant="secondary" label="Use work hours" onPress={() => save({ ...WORK, every: daily ? '1h' : built.every })} />
312 </Box>
313 )}
314 {built && <Box key={`cron-line:${entry}`}><Text dimColor wrap="wrap">{describeBuild(built)}</Text></Box>}
315 {!built && canType && (
316 <ui.Input key={`cron-input:${entry}`} value={custom === entry ? draft : cron} placeholder="0 9-18 * * 1-5"
317 onInput={(v: string) => update($, cronDraft, () => v)} onSubmit={(v: string) => update($, cronDraft, () => v).then(() => saveAll($))} />
318 )}
319 {!built && <Text dimColor>minute hour day month weekday</Text>}
320 </Box>
321 )
322 }
323
324 // A setting's field: typing keeps a draft; submitting it, or Save changes, saves what changed.
325 const setting = (f: typeof SETTINGS[number]) => row(f.path, f.label, canType
326 // A short value gets a short field; the rest take the line.
327 ? <Box key={`fill:set:${f.path}`} {...(SHORT_FIELDS[f.path] ? { width: SHORT_FIELDS[f.path], flexShrink: 0 } : { flexGrow: 1, minWidth: 0 })}>
328 <ui.Input key={`set:${f.path}`} value={changes?.[f.path] ?? f.of(doc)} placeholder={f.hint}
329 onInput={(v: string) => update($, settingsDraft, d => ({ ...(d ?? {}), [f.path]: v }))}
330 onSubmit={(v: string) => update($, settingsDraft, d => ({ ...(d ?? {}), [f.path]: v })).then(() => saveAll($))} />
331 </Box>
332 : <Text>{f.of(doc) || '—'}</Text>,
333 'unit' in f && <Text key={`unit:${f.path}`} dimColor>{f.unit}</Text>)
334 const toggle = (path: string, label: string, on: boolean) => checkbox(ui, look, `set:${path}`, label, on, () => saveConfig($, ['set', path, String(!on)]))
335
336 const reload = <Button key="reload-config" variant="secondary" hotkey={look.hotkey('k')} label="Reload" onPress={() => discard($).then(() => readPanes($))} />
337 if (!doc) {
338 return (
339 <Box flexDirection="column" rowGap={1}>
340 <Text dimColor>No config yet. Put it in ~/.on-my-watch/config.json, then press Reload.</Text>
341 <Box flexDirection="row">{reload}</Box>
342 </Box>
343 )
344 }
345 const repos = (Array.isArray(doc.repos) ? doc.repos : []) as any[]
346 const queries = repos.flatMap(r => r?.queries ?? [])
347 const envs = environments.length
348 // Settings typed and not saved yet, by the card they sit in: that card gets Save changes.
349 // Unsaved: the draft, settings typed, or a cron line typed.
350 const groupRows = await read($, envRows)
351 const dirty = !!dirtyDraft || !!groupRows || custom !== null || SETTINGS.some(f => changes?.[f.path] !== undefined && changes[f.path]!.trim() !== f.of(doc))
352 const saveBar = (
353 <Box key="save-bar" flexDirection="row" columnGap={1} alignItems="center" flexWrap="wrap">
354 {dirty && [
355 <Text key="unsaved" color="warning">Unsaved changes</Text>,
356 <Button key="discard-config" variant="secondary" label="Discard" onPress={() => discard($)} />,
357 <Button key="save-config" variant="primary" label="Save changes" onPress={() => saveAll($)} />,
358 ]}
359 {reload}
360 {/* Repositories and queries are edited in the file: its $schema gives the editor checks and completion. */}
361 <Button key="open-config" variant="secondary" label="Open in editor" onPress={() => openConfig($)} />
362 </Box>
363 )
364 const saveRow = (_section: string) => null
365 const newGroup = await read($, envDraft)
366 const stateLine = state && (
367 <Box flexDirection="row" columnGap={2} alignItems="center">
368 {state.paused
369 ? <Box key="schedule-state" flexDirection="row" columnGap={1} alignItems="center">{look.icon('pause', 'warning', 'paused')}<Text color="warning">{`Paused since ${when(state.paused)}`}</Text></Box>
370 : <Box key="schedule-state" flexDirection="row" columnGap={1} alignItems="center">{look.icon('dot', 'success', 'running')}<Text color="success">Running</Text><Text dimColor>{state.next ? ` · next ${when(state.next)}` : ''}</Text></Box>}
371 {state.paused
372 ? <Button key="resume" variant="secondary" label="Resume" onPress={() => scheduleCommand($, ['resume'])} />
373 : <Button key="pause" variant="secondary" label="Pause" onPress={() => scheduleCommand($, ['pause'])} />}
374 </Box>
375 )
376 // A refusal can name every query it breaks: the first three say enough.
377 const errorLines = saveError ? saveError.split('\n') : []
378 return (
379 <Box flexDirection="column" rowGap={1}>
380 {card('file', 'logs', 'Config file', saveBar,
381 <Box key="config-file"><Text dimColor>~/.on-my-watch/config.json</Text></Box>,
382 <Box key="config-counts">
383 <Text dimColor wrap="wrap">{[plural(repos.length, 'repository', 'repositories'), plural(queries.length, 'query', 'queries'), plural(envs, 'environment')].join(' · ')}</Text>
384 </Box>,
385 <Box key="config-state">
386 <Text color={problems.length ? 'error' : 'success'}>{`${problems.length ? 'not valid' : 'valid'}${readAt ? ` · read ${time(readAt)}` : ''}`}</Text>
387 </Box>,
388 ...problems.slice(0, 3).map((p: string) => <Text key={`problem:${p}`} color="error" wrap="truncate-end">{p}</Text>),
389 errorLines.length > 0 && (
390 <Box key="config-error" flexDirection="column">
391 <Text color="error" bold>Not saved:</Text>
392 {errorLines.slice(0, 3).map((l: string, i: number) => <Text key={`error:${i}`} color="error" wrap="truncate-end">{l}</Text>)}
393 {errorLines.length > 3 && <Text color="error">{`and ${errorLines.length - 3} more`}</Text>}
394 </Box>
395 ))}
396
397 {card('watch', 'metrics', 'General', null,
398 ...SETTINGS.filter(f => f.section === 'watch').flatMap(f => [setting(f),
399 ...(f.path === 'window' ? [row('hint:window-row', '', <Box key="hint:window"><Text dimColor>A number and m, h or d: 15m, 4h, 7d</Text></Box>)] : [])]),
400 saveRow('watch'))}
401
402 {(() => {
403 // Environment groups: each a name and the envs it covers, comma-separated; the panes show it as one.
404 const rows = (groupRows ?? Object.entries((doc.environments ?? {}) as Record<string, string[]>).map(([n, m]) => [n, m.join(', ')])) as [string, string][]
405 const setRows = (next: [string, string][]) => update($, envRows, () => next)
406 const field = (key: string, value: string, hint: string, onInput: (v: string) => unknown, width?: number) => (
407 <Box key={`fill:${key}`} {...(width ? { width, flexShrink: 0 } : { flexGrow: 1, minWidth: 0 })}>
408 <ui.Input key={key} value={value} placeholder={hint} onInput={onInput} onSubmit={() => saveAll($)} />
409 </Box>
410 )
411 // On the desktop each group is its name and chips, each taking effect at once; the terminal, which draws no
412 // dropdown to pick from, types members as a comma-separated list.
413 const chips = !look.isTerminal
414 const known = [...new Set([...environments.flatMap(([, m]) => m), ...rows.flatMap(([, m]) => list(m))])]
415 const change = (next: [string, string][]) => setRows(next).then(() => saveAll($))
416 const removeButton = (i: number) => (
417 <Button key={`env-remove:${i}`} plain dimColor hover={{ color: 'error' }} label="Remove" onPress={() => (chips ? change : setRows)(rows.filter((_, j) => j !== i))} />
418 )
419 const chip = (key: string, text: string, drop: () => unknown) => (
420 <Box key={key} flexDirection="row" columnGap={1} alignItems="center" borderStyle="round" borderColor="subtle" paddingX={1}>
421 <Text>{text}</Text>
422 <Button key={`${key.replace(':', '-remove:')}`} plain dimColor label="×" onPress={drop} />
423 </Box>
424 )
425 return card('envs', 'list', 'Environments', <Text dimColor>Shown as one in Watch</Text>,
426 ...(rows.length ? [] : [<Box key="envs-none"><Text dimColor>No groups: each environment shows on its own.</Text></Box>]),
427 ...rows.map(([name, members], i) => chips
428 ? (
429 <Box key={`env-row:${i}`} flexDirection="column" rowGap={1}>
430 <Box flexDirection="row" columnGap={1} alignItems="center" justifyContent="space-between">
431 {field(`env-group:${i}`, name, 'Name', v => setRows(rows.map((r, j) => (j === i ? [v, r[1]] : r))), ENV_GROUP_WIDTH)}
432 {removeButton(i)}
433 </Box>
434 <Box flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1} alignItems="center">
435 {list(members).map(m => chip(`env-chip:${i}:${m}`, m, () => change(rows.map((r, j) => (j === i ? [r[0], list(r[1]).filter(x => x !== m).join(', ')] : r)))))}
436 {picker(`env-add:${i}`, '', [{ value: '', label: '+ add' }, ...known.filter(k => !list(members).includes(k)).map(k => ({ value: k, label: k }))], '',
437 (v: string) => v && change(rows.map((r, j) => (j === i ? [r[0], [...list(r[1]), v].join(', ')] : r))))}
438 </Box>
439 </Box>
440 )
441 : canType && narrow
442 // Narrow: the name and Remove, then the members on a line of their own.
443 ? (
444 <Box key={`env-row:${i}`} flexDirection="column">
445 <Box flexDirection="row" columnGap={1} alignItems="center" justifyContent="space-between">
446 {field(`env-group:${i}`, name, 'Name', v => setRows(rows.map((r, j) => (j === i ? [v, r[1]] : r))), ENV_GROUP_WIDTH)}
447 {removeButton(i)}
448 </Box>
449 {field(`env-members:${i}`, members, 'Production, Canary', v => setRows(rows.map((r, j) => (j === i ? [r[0], v] : r))))}
450 </Box>
451 )
452 : (
453 <Box key={`env-row:${i}`} flexDirection="row" columnGap={1} alignItems="center">
454 {canType ? [
455 field(`env-group:${i}`, name, 'Name', v => setRows(rows.map((r, j) => (j === i ? [v, r[1]] : r))), ENV_GROUP_WIDTH),
456 field(`env-members:${i}`, members, 'Production, Canary', v => setRows(rows.map((r, j) => (j === i ? [r[0], v] : r)))),
457 ] : <Text>{`${name}: ${members}`}</Text>}
458 {removeButton(i)}
459 </Box>
460 )),
461 canType && (
462 <Box key="env-row:new" flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1} alignItems="center">
463 {field('env-group-new', newGroup.name, 'Name, such as Production', v => update($, envDraft, x => ({ ...x, name: v })), ENV_GROUP_WIDTH)}
464 {chips
465 ? picker('env-new-first', '', [{ value: '', label: 'Pick an environment' }, ...known.map(k => ({ value: k, label: k }))], list(newGroup.members)[0] ?? '',
466 (v: string) => update($, envDraft, x => ({ ...x, members: v })))
467 : field('env-members-new', newGroup.members, 'Production, Canary', v => update($, envDraft, x => ({ ...x, members: v })))}
468 <Button key="env-add" variant="primary" label="Add" onPress={async () => {
469 if (!newGroup.name.trim() || !list(newGroup.members).length) return update($, configError, () => 'A group needs a name and at least one environment.')
470 await (chips ? change : setRows)([...rows, [newGroup.name.trim(), newGroup.members]])
471 await update($, envDraft, () => ({ name: '', members: '' }))
472 }} />
473 </Box>
474 ))
475 })()}
476
477 {card('prs', 'merged', 'Pull requests', null,
478 ...(look.isTerminal
479 ? [...SETTINGS.filter(f => f.section === 'prs').map(setting),
480 row('hint:bots-row', '', <Box key="hint:bots"><Text dimColor>Separate usernames with commas: dependabot, renovate</Text></Box>)]
481 // On the desktop, a chip per bot; typing a name and Enter adds one.
482 : [row('bots', 'Bots', <Box key="bot-chips" flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1} alignItems="center">
483 {((doc.bots ?? ['dependabot']) as string[]).map(b => (
484 <Box key={`bot-chip:${b}`} flexDirection="row" columnGap={1} alignItems="center" borderStyle="round" borderColor="subtle" paddingX={1}>
485 <Text>{b}</Text>
486 <Button key={`bot-chip-remove:${b}`} plain dimColor label="×"
487 onPress={() => saveConfig($, ['set', 'bots', JSON.stringify(((doc.bots ?? ['dependabot']) as string[]).filter(x => x !== b))]).then(() => saveAll($))} />
488 </Box>
489 ))}
490 {canType && <Box key="fill:bot-add" width={16} flexShrink={0}>
491 <ui.Input key="bot-add" value="" placeholder="+ add a bot"
492 onSubmit={(v: string) => v.trim() && saveConfig($, ['set', 'bots', JSON.stringify([...new Set([...((doc.bots ?? ['dependabot']) as string[]), v.trim()])])]).then(() => saveAll($))} />
493 </Box>}
494 </Box>)]),
495 saveRow('prs'))}
496
497 {card('notify', 'bell', 'Panes and notifications', muteControl(ui, look, $, await read($, muted)),
498 row('panes', 'Show', toggle('panes.watch', 'Watch', doc.panes?.watch !== false), toggle('panes.pull_requests', 'Pull requests', doc.panes?.pull_requests !== false)),
499 row('notify', 'Notify', toggle('notify.watch', 'Watch', doc.notify?.watch === true), toggle('notify.pull_requests', 'Pull requests', doc.notify?.pull_requests === true)))}
500
501 {card('schedule', 'schedule', 'Schedule', stateLine,
502 ...(!doc.schedule ? [<Text key="no-schedule" dimColor>No schedule yet.</Text>] : [
503 group('watch-schedule', 'Watch'),
504 cronPicker('', doc.schedule),
505 look.divider('sep:prs-schedule'),
506 group('prs-schedule', 'Pull requests'),
507 cronPicker('prs', doc.pullRequestsSchedule ?? doc.schedule, [{ value: 'default', label: 'Same as Watch' }], !doc.pullRequestsSchedule && custom !== 'prs'),
508 ...(ownSchedules(doc).length ? [
509 look.divider('sep:own'),
510 group('own', 'Own schedules'),
511 ...ownSchedules(doc).map(o => (
512 <Box key={`own:${o.entry}`} flexDirection="column" rowGap={1} borderStyle="round" borderColor="subtle" paddingX={1}>
513 <Text wrap="truncate-end">{o.entry.split('|').filter(Boolean).join(' › ')}</Text>
514 {cronPicker(o.entry, o.schedule, [{ value: 'default', label: 'Same as every query' }])}
515 </Box>
516 )),
517 ] : []),
518 ]))}
519
520 </Box>
521 )
522}
523
524// The engine caps a process at 10 minutes; a scheduled run has no cap.
525async function checker($: any, args: string[], stdin?: string) {
526 const script = `${$.plugin.root}/bin/on_my_watch.py`
527 const timeoutMs = args[0] === 'run' ? 600_000 : 30_000
528 // Windows has no /usr/bin/env and names it python; its own PATH already finds the tools.
529 if ((await $.env.get('OS')) === 'Windows_NT') return $.process.run(['python', script, ...args], { timeoutMs, stdin })
530 const path = [await $.env.get('PATH'), TOOL_DIRS].filter(Boolean).join(':')
531 return $.process.run(['/usr/bin/env', 'python3', script, ...args], { env: { PATH: path }, timeoutMs, stdin })
532}
533
534async function load($: any) {
535 const done = await checker($, ['latest'])
536 if (done.exitCode !== 0) return
537 const latest = JSON.parse(done.stdout || '{}')
538 await update($, result, () => (latest.checkedAt || latest.prsCheckedAt ? latest : null))
539 const quiet = await checker($, ['notifications', 'status'])
540 if (quiet.exitCode === 0) {
541 const state = JSON.parse(quiet.stdout || '{}')
542 await update($, muted, () => state.muted === true)
543 await update($, quietEntries, () => state.quiet ?? [])
544 }
545 const scheduled = await checker($, ['schedule', 'status'])
546 if (scheduled.exitCode === 0) await update($, scheduleState, () => JSON.parse(scheduled.stdout || 'null'))
547 // Pictures change only when pull requests are fetched: read them again only then, not every minute.
548 if (latest.prsCheckedAt && latest.prsCheckedAt !== picturesFor) {
549 const pictures = await checker($, ['avatars'])
550 if (pictures.exitCode === 0) {
551 await update($, avatars, () => JSON.parse(pictures.stdout || '{}'))
552 picturesFor = latest.prsCheckedAt
553 }
554 }
555}
556// The pull request fetch the pictures in `avatars` were read after.
557let picturesFor: string | undefined
558
559// `panes` in ~/.on-my-watch/config.json; a pane left out is on.
560// Every environment the config knows, as [group, members]: the Environments section's groups in its order, then each
561// env the queries use that no group names, on its own. The Watch filter, the query form and the counts all read this.
562function environmentList(doc: any): [string, string[]][] {
563 const groups = Object.entries((doc?.environments ?? {}) as Record<string, string[]>).filter(([, m]) => Array.isArray(m))
564 const named = new Set(groups.flatMap(([, m]) => m))
565 const loose = [...new Set(((doc?.repos ?? []) as any[]).flatMap(r => r?.queries ?? []).map((q: any) => q.env).filter(Boolean))] as string[]
566 return [...groups, ...loose.filter(e => !named.has(e)).map(e => [e, [e]] as [string, string[]])]
567}
568
569async function readPanes($: any) {
570 let panes: Partial<Record<Part, boolean>> = {}
571 try {
572 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
573 const config = JSON.parse(await $.fs.read(`${home}/.on-my-watch/config.json`))
574 panes = config.panes ?? {}
575 await update($, configDoc, () => config)
576 // A refusal belongs to the file it was made against: a fresh read leaves it behind.
577 await update($, configError, () => '')
578 await update($, configReadAt, () => new Date().toISOString())
579 const checked = await checker($, ['validate'])
580 const verdict = checked.stdout.trim()
581 await update($, configProblems, () => (checked.exitCode === 0 && verdict === 'config.json holds to the schema' ? [] : (verdict || checked.stderr.trim()).split('\n')))
582 const repos = (Array.isArray(config.repos) ? config.repos : []) as any[]
583 const queries = repos.flatMap(repo => repo?.queries ?? [])
584 const distinct = (values: unknown[]) => [...new Set(values.filter(v => v !== undefined && v !== null && v !== ''))]
585 await update($, choices, () => ({
586 names: repos.map(repo => repo?.name).filter(Boolean),
587 // A grouped env shows as its group, once; the rest as themselves.
588 envs: environmentList(config).map(([group]) => group),
589 envGroups: (config.environments && typeof config.environments === 'object' ? config.environments : {}) as Record<string, string[]>,
590 // The priorities the queries use, most urgent first, by name: High, Medium, Low.
591 priorities: (distinct(queries.map((q: any) => q.priority)) as number[]).sort((a, b) => a - b).map(n => PRIORITY_NAMES[n - 1] ?? `P${n}`),
592 bots: Array.isArray(config.bots) ? config.bots : ['dependabot'],
593 }))
594 } catch {} // no config yet: both panes, the Watch one saying where it goes
595 return { watch: panes.watch !== false, pull_requests: panes.pull_requests !== false }
596}
597
598// The newest run and the config, so the panes draw them however they come to open.
599async function ready($: any) {
600 await load($)
601 return readPanes($)
602}
603
604async function show($: any) {
605 try {
606 const on = await ready($)
607 for (const part of ['watch', 'pull_requests'] as const) if (on[part]) await $.ui.open(PANES[part])
608 await $.ui.open(CONFIG_PANE)
609 // The last pane opened is shown, and opening an open one only retitles it: focus brings Watch to the front.
610 if (on.watch) await $.ui.open({ ...PANES.watch, focus: true })
611 } catch (error) {
612 $.ui.toast(`on-my-watch: ${String(error)}`)
613 }
614}
615
616// Set only by a run this load of the module started. State outlives a reload but the run doesn't, so a `running`
617// flag with this unset is left over from before the reload and must not hold Refresh back.
618let runHere = false
619
620async function checkNow($: any, part: Part | 'all') {
621 if (runHere) return // one run at a time
622 runHere = true
623 await update($, running, () => part)
624 await update($, runningSince, () => new Date().toISOString())
625 try {
626 const chosen = part === 'pull_requests' ? '' : await read($, watchWindow)
627 const done = await checker($, [...(part === 'all' ? ['run'] : ['run', part]), ...(chosen ? ['--window', chosen] : [])])
628 if (done.exitCode !== 0) $.ui.toast(`on-my-watch: check failed: ${done.stderr.trim().split('\n').pop()}`)
629 await load($)
630 } finally {
631 runHere = false
632 await update($, running, () => null)
633 }
634}
635
636// pup signs in through the browser and waits for it, up to the engine's 10-minute cap; then Watch checks again.
637async function signIn($: any) {
638 const path = [await $.env.get('PATH'), TOOL_DIRS].filter(Boolean).join(':')
639 const done = await $.process.run(['pup', 'auth', 'login'], { env: { PATH: path }, timeoutMs: 600_000 })
640 if (done.exitCode !== 0) return $.ui.toast(`on-my-watch: sign-in failed: ${done.stderr.trim().split('\n').pop()}`)
641 await checkNow($, 'watch')
642}
643
644// Opt-in AI: nothing reaches Claude until Investigate is pressed. `investigate` in the config picks the
645// session (a new one by default, or this one), a skill the problem is handed to, and the `prompt` and
646// `problem` templates; a query's own `investigate` overrides each template for its problem.
647async function investigate($: any, r: Result, problems: Problem[], surface: string) {
648 const first = problems[0]!
649 const config = (await read($, configDoc)) as any
650 const options = config?.investigate ?? {}
651 const own = (config?.repos ?? []).find((c: any) => c.name === first.name)?.queries
652 ?.find((q: any) => q.service === first.service && q.env === first.env && q.label === first.label)?.investigate ?? {}
653 const prompt = own.prompt ?? options.prompt
654 const problem = own.problem ?? options.problem
655 const fill = (template: string, values: Record<string, unknown>) =>
656 template.replace(/\{\{(\w+)\}\}/g, (all, name) => (name in values ? String(values[name] ?? '') : all))
657 const lines = problems.map(p => problem
658 ? fill(problem, { name: p.name, repo: p.repo, env: p.env, priority: p.priority != null ? `P${p.priority}` : '', service: p.service,
659 label: p.label, value: p.value, alert: p.alert, source: p.source, query: p.query, link: p.link })
660 : `- ${p.priority != null ? `P${p.priority} ` : ''}${p.service ? `${p.service}: ` : ''}${p.label} is ${p.value}${p.alert ? ` (${p.alert})` : ''}, ${p.source} query \`${p.query}\`. Datadog: ${p.link}`)
661 const body = prompt
662 ? fill(prompt, { name: first.name, repo: first.repo, env: first.env, window: r.window, checkedAt: r.checkedAt,
663 from: r.span?.from, to: r.span?.to, problems: lines.join('\n') })
664 : [`Investigate ${first.name} (${first.repo}) in ${first.env}, in the ${r.window} before ${r.checkedAt ?? 'the last check'}.`, ...lines].join('\n')
665 if (options.session !== 'current' && (await openSession($, options.skill ? `/${options.skill} ${body}` : body, surface))) return
666 if (options.skill) await $.command.run({ command: options.skill, args: body })
667 else await $.prompt.submit({ text: body })
668}
669
670// A new interactive session in this session's folder: the desktop app's own link, or a Terminal window.
671// shortcut: macOS only (`open`, Terminal.app); elsewhere the command fails and Investigate stays in this session.
672async function openSession($: any, text: string, surface: string) {
673 const cwd = await $.session.cwd()
674 const quote = (s: string) => `'${s.replaceAll("'", `'\\''`)}'`
675 const argv = surface === 'terminal'
676 ? ['osascript', '-e', 'on run argv', '-e', 'tell application "Terminal" to activate', '-e', 'tell application "Terminal" to do script (item 1 of argv)', '-e', 'end run',
677 // $'…' keeps the prompt's lines on one command line, so Terminal runs it as typed.
678 `cd ${quote(cwd)} && claude $'${text.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n')}'`]
679 : ['open', `claude://code/new?${new URLSearchParams({ q: text, folder: cwd })}`]
680 try {
681 if ((await $.process.run(argv)).exitCode === 0) return true
682 } catch {}
683 await $.ui.toast('on-my-watch: could not open a new session, investigating here')
684 return false
685}
686
687// Opens a file or an address with the system's opener, on the machine Claude runs on. The terminal presses these in
688// place of links: one without link support prints a link's whole URL beside its text.
689async function openWith($: any, target: string) {
690 const windows = (await $.env.get('OS')) === 'Windows_NT'
691 for (const argv of windows ? [['cmd', '/c', 'start', '', target]] : [['open', target], ['xdg-open', target]]) {
692 try {
693 if ((await $.process.run(argv)).exitCode === 0) return
694 } catch {}
695 }
696 $.ui.toast(`on-my-watch: could not open ${target}`)
697}
698async function openConfig($: any) {
699 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
700 await openWith($, `${home}/.on-my-watch/config.json`)
701}
702
703// Mutes every notification, Watch and Pull requests alike, scheduled runs included, until unmuted.
704async function toggleMute($: any) {
705 const done = await checker($, ['notifications', (await read($, muted)) ? 'on' : 'off'])
706 if (done.exitCode === 0) await update($, muted, () => JSON.parse(done.stdout || '{}').muted === true)
707}
708
709// One entry's notifications on or off; the general Mute still silences everything.
710const entryKey = (p: Problem) => `${p.name}|${p.service}|${p.env}|${p.label}`
711async function toggleEntry($: any, p: Problem) {
712 const off = (await read($, quietEntries)).includes(entryKey(p))
713 const done = await checker($, ['notifications', off ? 'loud' : 'quiet', entryKey(p)])
714 if (done.exitCode === 0) await update($, quietEntries, () => JSON.parse(done.stdout || '{}').quiet ?? [])
715}
716
717async function openHistory($: any) {
718 const done = await checker($, ['history'])
719 if (done.exitCode === 0) await update($, runs, () => JSON.parse(done.stdout || '[]'))
720 await update($, watchView, () => 'history')
721}
722
723// Pause and resume write a flag file the scheduled run reads: the schedule itself stays installed.
724async function scheduleCommand($: any, args: string[]) {
725 const done = await checker($, ['schedule', ...args])
726 if (done.exitCode !== 0) return done.stderr.trim().split('\n').pop() || 'the schedule could not be changed'
727 await update($, scheduleState, () => JSON.parse(done.stdout))
728 return ''
729}
730
731// A cron line for every query (entry '') or one query's own; 'default' drops a query's own.
732// A cron line into the draft: '' is Watch's, 'prs' the pull requests', else a query's own by its entry; 'default' drops it.
733async function setSchedule($: any, cron: string, entry: string) {
734 const doc = ((await read($, configDraft)) ?? (await read($, configDoc))) as any
735 const path = entry === '' ? 'schedule' : entry === 'prs' ? 'pullRequestsSchedule'
736 : ownSchedules(doc).find(o => o.entry === entry)?.path
737 if (path) await saveConfig($, cron === 'default' ? ['delete', path] : ['set', path, JSON.stringify(cron.trim())])
738 await update($, customFor, () => null)
739}
740// The queries with a schedule of their own: where each sits in the config, its entry, and its cron line.
741function ownSchedules(doc: any) {
742 return ((doc?.repos ?? []) as any[]).flatMap((r, i) => ((r?.queries ?? []) as any[]).map((q, j) => ({ q, j }))
743 .filter(({ q }) => q?.schedule)
744 .map(({ q, j }) => ({ path: `repos.${i}.queries.${j}.schedule`, entry: [r.name, q.service, q.env, q.label].join('|'), schedule: q.schedule as string })))
745}
746
747async function openRun($: any, id: number) {
748 const done = await checker($, ['show', String(id)])
749 if (done.exitCode !== 0) return $.ui.toast(`on-my-watch: run ${id} could not be read`)
750 await update($, archived, () => JSON.parse(done.stdout))
751 await update($, watchView, () => 'archived')
752}
753
754const window = (span?: { from: string; to: string }) => (span ? `${time(span.from)}–${time(span.to)}` : '')
755// A run's day as people say it: Today, Yesterday, else the weekday and date.
756function day(iso: string) {
757 const at = new Date(iso)
758 const start = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
759 const ago = Math.round((start(new Date()) - start(at)) / 86_400_000)
760 return ago === 0 ? 'Today' : ago === 1 ? 'Yesterday' : at.toLocaleDateString([], { weekday: 'short', day: 'numeric', month: 'short' })
761}
762// A row's runs from where its history begins.
763const fromStart = (all: (number | null)[]) => all.slice(Math.max(0, all.findIndex(v => v != null)))
764// Two counts draw a hook, not a trend: a row's chart waits for a third.
765const charted = (p: Problem) => !!p.trend && !p.trend.isNew && p.trend.values.filter(v => v != null).length >= 3
766const sinceText = (p: Problem) => (p.trend?.since && !p.trend.isNew ? `since ${short(p.trend.since)}` : '')
767// Each value placed between the lowest and highest of the values and the threshold, 0 to 1; null stays null.
768function spark(all: (number | null)[], threshold?: number | null) {
769 const values = fromStart(all)
770 const seen = [...values, threshold].filter((v): v is number => v != null)
771 const low = Math.min(...seen), span = Math.max(...seen) - low
772 const at = (v: number) => (span ? (v - low) / span : 0.5)
773 return { points: values.map(v => (v == null ? null : at(v))), line: threshold == null ? null : at(threshold) }
774}
775// A tinted box in the priority's colour: the threshold dashed, the runs as a line with the area under it shaded.
776// A run with no count (stored before every count was kept) breaks the line.
777function sparkSvg(values: (number | null)[], hex: string, threshold: number | null | undefined, width: number) {
778 const pad = 3, h = 20, { points, line } = spark(values, threshold)
779 const x = (i: number) => (points.length > 1 ? pad + (i * (width - 2 * pad)) / (points.length - 1) : width - pad)
780 const y = (v: number) => h - pad - v * (h - 2 * pad)
781 const runs: [number, number][][] = [[]]
782 points.forEach((v, i) => (v == null ? runs.push([]) : runs.at(-1)!.push([x(i), y(v)])))
783 const xy = (p: [number, number]) => `${p[0].toFixed(1)},${p[1].toFixed(1)}`
784 const marks = runs.filter(r => r.length).map(r => (r.length > 1
785 ? `<path d="M${r[0]![0].toFixed(1)},${h - pad} L${r.map(xy).join(' L')} L${r.at(-1)![0].toFixed(1)},${h - pad} Z" fill="${hex}" fill-opacity="0.12"/>`
786 + `<polyline points="${r.map(xy).join(' ')}" fill="none" stroke="${hex}" stroke-width="1.5" stroke-linejoin="round"/>`
787 : `<circle cx="${r[0]![0].toFixed(1)}" cy="${r[0]![1].toFixed(1)}" r="1.4" fill="${hex}"/>`))
788 const last = points.at(-1) == null ? null : [x(points.length - 1), y(points.at(-1)!)] as [number, number]
789 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${h}">`
790 + `<rect width="${width}" height="${h}" rx="4" fill="${hex}" fill-opacity="0.08"/>`
791 + (line == null ? '' : `<line x1="${pad}" y1="${y(line).toFixed(1)}" x2="${width - pad}" y2="${y(line).toFixed(1)}" stroke="${hex}" stroke-opacity="0.55" stroke-dasharray="2 2"/>`)
792 + marks.join('') + (last ? `<circle cx="${last[0].toFixed(1)}" cy="${last[1].toFixed(1)}" r="2.2" fill="${hex}"/>` : '') + '</svg>'
793}
794
795const time = (iso: string) => new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
796const plural = (n: number, word: string, many = `${word}s`) => `${n} ${n === 1 ? word : many}`
797function age(iso: string) {
798 const minutes = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 60_000))
799 return minutes < 60 ? `${minutes}m` : minutes < 1440 ? `${Math.round(minutes / 60)}h` : `${Math.round(minutes / 1440)}d`
800}
801
802const DAY = 86_400_000
803// Rows drawn per page. The desktop app stops drawing a pane whose tree grows past what it takes: 60 closed pull
804// requests with their reviewers' pictures drew, 90 did not, nor one repository's 142 queries in Config.
805const PAGE = 30
806// Page numbers under a long list: ‹ 1 2 3 … 10 ›, the page shown as the main button. Nothing for one page.
807function pager(ui: any, key: string, page: number, pages: number, go: (n: number) => unknown) {
808 if (pages <= 1) return null
809 const shown = pages <= 7 ? Array.from({ length: pages }, (_, i) => i + 1)
810 : [...new Set([1, page - 1, page, page + 1, pages].filter(n => n >= 1 && n <= pages))].sort((a, b) => a - b)
811 const items: unknown[] = []
812 if (page > 1) items.push(<ui.Button key={`${key}-prev`} variant="secondary" label="‹" onPress={() => go(page - 1)} />)
813 shown.forEach((n, i) => {
814 if (i > 0 && n - shown[i - 1]! > 1) items.push(<ui.Text key={`${key}-gap:${n}`} dimColor>…</ui.Text>)
815 items.push(<ui.Button key={`${key}:${n}`} variant={n === page ? 'primary' : 'secondary'} label={String(n)} onPress={() => go(n)} />)
816 })
817 if (page < pages) items.push(<ui.Button key={`${key}-next`} variant="secondary" label="›" onPress={() => go(page + 1)} />)
818 return <ui.Box key={`${key}-pager`} flexDirection="row" columnGap={1} alignItems="center" marginTop={1}>{items}</ui.Box>
819}
820// When a refresh last ran, as the panes say it: `Last refresh 16:02`, or with the day when not today.
821// A time today, else with its day: a time alone from yesterday would read as later than today's.
822const when = (iso: string) => (day(iso) === 'Today' ? time(iso) : `${day(iso)} ${time(iso)}`)
823// The same, short enough for a row: a weekday this week, else the date.
824const short = (iso: string) => {
825 const days = (Date.now() - new Date(iso).getTime()) / 86_400_000
826 const on = day(iso) === 'Today' ? '' : days < 6 ? new Date(iso).toLocaleDateString([], { weekday: 'short' }) : new Date(iso).toLocaleDateString([], { day: 'numeric', month: 'short' })
827 return on ? `${on} ${time(iso)}` : time(iso)
828}
829const refreshed = (iso: string) => `Last refresh ${when(iso)}`
830const SPANS = [
831 { value: 'today', label: 'Today', since: () => new Date().setHours(0, 0, 0, 0) },
832 { value: '3d', label: 'Last 3 days', since: () => Date.now() - 3 * DAY },
833 { value: 'week', label: 'Last week', since: () => Date.now() - 7 * DAY },
834 { value: 'month', label: 'Last month', since: () => Date.now() - 30 * DAY },
835]
836// What Refresh checks in Watch; empty is the config's window, which the schedule always uses.
837const WINDOWS = [
838 { value: '', label: 'Config window' },
839 { value: '1m', label: 'Last minute' },
840 { value: '5m', label: 'Last 5 minutes' },
841 { value: '15m', label: 'Last 15 minutes' },
842 { value: '30m', label: 'Last 30 minutes' },
843 { value: '1h', label: 'Last hour' },
844 { value: '4h', label: 'Last 4 hours' },
845 { value: '12h', label: 'Last 12 hours' },
846 { value: '1d', label: 'Last day' },
847 { value: '7d', label: 'Last 7 days' },
848]
849// Below this many cells a Watch row's label and its facts don't fit on one line: priority, label, icon, value, an
850// alert line such as `above 19` and the bell take about this much.
851const NARROW = 64
852const WIDE = 100
853// A chart's column, in cells; the long one, on a narrow row's own line, is twice that.
854const CHART_CELLS = 10
855// A run's window in words, as the picker names it: `1h` is "last hour".
856const windowLabel = (w: string) => (WINDOWS.find(o => o.value === w)?.label ?? `Last ${w}`).toLowerCase()
857// A dropdown where the surface draws one and isn't a terminal; else a button that steps to the next value.
858function pickerFor(ui: any, surface: string) {
859 const Select = 'Select' in ui && surface !== 'terminal' ? ui.Select : undefined
860 const { Button } = ui
861 return (key: string, hotkey: string, options: readonly { value: string; label: string }[], value: string, set: (v: string) => unknown) => {
862 if (Select) return <Select key={key} options={options} value={value} onSelect={(v: string) => set(v)} />
863 const at = Math.max(0, options.findIndex(o => o.value === value))
864 return <Button key={key} plain hotkey={hotkey || undefined} label={options[at]!.label} onPress={() => set(options[(at + 1) % options.length]!.value)} />
865 }
866}
867// A pull request idle this many days has stalled: its idle time turns amber.
868const STALE_DAYS = 7
869// Whose move it is on an open pull request, and in which tone.
870function waitingOn(pr: PullRequest, me: string | null): readonly [string, Tone] | null {
871 const pending = (pr.reviewers ?? []).filter(r => r.review === 'pending').map(r => r.login)
872 if (pr.conflicts) return ['Conflicts', 'warning']
873 if (pr.review === 'CHANGES_REQUESTED') return ['Changes requested', 'error']
874 if (pr.draft) return ['Draft', 'muted']
875 if (pr.review === 'APPROVED' && pr.ci !== 'failing') return ['Ready to merge', 'success']
876 if (me && pending.includes(me)) return ['Waiting on you', ACCENT]
877 if (pending.length) return [`Waiting on ${pending[0]}${pending.length > 1 ? ` +${pending.length - 1}` : ''}`, 'muted']
878 return ['Waiting on review', 'muted']
879}
880// Open and on you: your review is asked for, or it is yours and changes were asked for or CI is red.
881const needsYou = (pr: PullRequest, me: string | null) => !!me && (pr.state ?? 'OPEN') === 'OPEN' && (
882 (pr.reviewers ?? []).some(r => r.review === 'pending' && r.login === me)
883 || (pr.author === me && (pr.review === 'CHANGES_REQUESTED' || pr.ci === 'failing')))
884// A PR opened in the last day gets the "new" mark.
885const isNew = (pr: PullRequest) => Date.now() - new Date(pr.createdAt).getTime() < DAY
886
887// When a PR last moved: closed ones when they closed, open ones when last updated.
888const moved = (pr: PullRequest) => pr.closedAt ?? pr.updatedAt ?? pr.createdAt
889const freshest = (a: PullRequest, b: PullRequest) => moved(b).localeCompare(moved(a))
890
891// PRs active in the span, grouped by repo; repos and PRs freshest first.
892function repos(prs: PullRequest[], chosen: string, bots: string[], state: string) {
893 const since = (SPANS.find(s => s.value === chosen) ?? SPANS[1]!).since()
894 // Runs stored before `state` existed held open PRs only.
895 const inState = prs.filter(pr => (pr.state ?? 'OPEN') === state.toUpperCase())
896 const fresh = inState
897 .filter(pr => new Date(moved(pr)).getTime() >= since)
898 // gh names bot accounts app/<name>; the config names them bare, in any case.
899 .filter(pr => !bots.includes(pr.author?.replace(/^app\//, '').toLowerCase() ?? ''))
900 .sort(freshest)
901 const byRepo = new Map<string, PullRequest[]>()
902 for (const pr of fresh) byRepo.set(pr.repo, [...(byRepo.get(pr.repo) ?? []), pr])
903 return { groups: [...byRepo], shown: fresh.length }
904}
905
906
907// Our own icons: 16x16, one colour. An Svg is an image and can't take theme colours, so each tone has a mid
908// hex that reads on light and dark; the terminal, which draws no Svg, gets the glyph in the theme colour.
909const TONES = {
910 error: ['#D0485C', 'error'],
911 warning: ['#C48A00', 'warning'],
912 success: ['#2F9E44', 'success'],
913 merged: ['#8957E5', 'merged'],
914 accent: ['#D97757', 'claude'],
915 info: ['#4C6EF5', 'suggestion'],
916 muted: ['#8A8A8A', 'inactive'],
917} as const
918type Tone = keyof typeof TONES
919const stroke = 'fill="none" stroke="C" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"'
920const ICONS = {
921 dot: ['<circle cx="8" cy="8" r="4.5" fill="C"/>', '●'],
922 check: [`<path ${stroke} d="M3 8.5l3 3 7-7"/>`, '✓'],
923 cross: [`<path ${stroke} d="M4 4l8 8M12 4l-8 8"/>`, '✗'],
924 ring: [`<circle ${stroke} cx="8" cy="8" r="5"/>`, '○'],
925 draft: [`<circle ${stroke} stroke-dasharray="2.5 2" cx="8" cy="8" r="5"/>`, '◌'],
926 merged: [`<g ${stroke}><circle cx="5" cy="3.5" r="1.6"/><circle cx="5" cy="12.5" r="1.6"/><circle cx="11.5" cy="8.5" r="1.6"/><path d="M5 5.1v5.8M5 5.5c0 2.5 4.8 1.2 4.9 3"/></g>`, '●'],
927 closed: [`<g ${stroke}><circle cx="8" cy="8" r="5"/><path d="M4.5 11.5l7-7"/></g>`, '⊘'],
928 spark: ['<path fill="C" d="M8 1.5l1.7 4.8 4.8 1.7-4.8 1.7L8 14.5l-1.7-4.8L1.5 8l4.8-1.7z"/>', '✦'],
929 warning: ['<path fill="C" d="M8 1.8l6.6 11.7H1.4z"/>', '!'],
930 error: [`<g><circle fill="C" cx="8" cy="8" r="6.5"/><path stroke="#fff" stroke-width="1.8" stroke-linecap="round" d="M5.6 5.6l4.8 4.8M10.4 5.6l-4.8 4.8"/></g>`, '✗'],
931 bell: [`<g ${stroke}><path d="M4 11.5h8l-1-1.6V7a3 3 0 0 0-6 0v2.9z"/><path d="M7 13.6h2"/></g>`, '!'],
932 logs: [`<g ${stroke}><path d="M4 1.8h5l3 3v9.4H4z"/><path d="M6 8h4M6 10.6h4"/></g>`, ''],
933 metrics: [`<g ${stroke}><path d="M2 13.5h12"/><path d="M2.5 10.5l3-3.5 2.8 2.2L13.5 3"/></g>`, ''],
934 traces: ['<g fill="C"><rect x="1.5" y="2.2" width="9" height="2.8" rx="1.2"/><rect x="4.5" y="6.6" width="8.5" height="2.8" rx="1.2"/><rect x="8" y="11" width="6.5" height="2.8" rx="1.2"/></g>', ''],
935 history: [`<g ${stroke}><circle cx="8" cy="8" r="5.8"/><path d="M8 4.8V8l2.2 1.6"/></g>`, ''],
936 group: [`<g ${stroke}><rect x="2" y="2" width="12" height="5" rx="1.5"/><rect x="2" y="9" width="12" height="5" rx="1.5"/></g>`, ''],
937 list: [`<path ${stroke} d="M2.5 4h11M2.5 8h11M2.5 12h11"/>`, ''],
938 schedule: [`<g ${stroke}><rect x="2" y="3" width="12" height="11" rx="1.5"/><path d="M2 6.5h12M5.5 1.8v2.4M10.5 1.8v2.4M8 8.6v2.2l1.5 1"/></g>`, ''],
939 pause: ['<g fill="C"><rect x="4" y="3" width="3" height="10" rx="0.8"/><rect x="9" y="3" width="3" height="10" rx="0.8"/></g>', ''],
940 play: ['<path fill="C" d="M5 2.8v10.4L13 8z"/>', ''],
941 bellOff: [`<g ${stroke}><path d="M4 11.5h8l-1-1.6V7a3 3 0 0 0-6 0v2.9z"/><path d="M7 13.6h2M2.5 2.5l11 11"/></g>`, ''],
942 // A dog's face: the watch dog that investigates.
943 comment: [`<path ${stroke} d="M2.5 3.5h11v7.5H8l-3 2.5V11H2.5z"/>`, ''],
944 investigate: [`<g ${stroke}><path d="M4.6 6C4 3.6 2 3.8 1.8 6c-.2 2 .5 3.6 1.8 4.2"/><path d="M11.4 6c.6-2.4 2.6-2.2 2.8 0 .2 2-.5 3.6-1.8 4.2"/><path d="M4.3 7.4c0-2.3 1.7-3.6 3.7-3.6s3.7 1.3 3.7 3.6v3.2c0 1.9-1.7 3.2-3.7 3.2s-3.7-1.3-3.7-3.2z"/></g><g fill="C"><circle cx="6.4" cy="8.3" r=".85"/><circle cx="9.6" cy="8.3" r=".85"/><ellipse cx="8" cy="11" rx="1.3" ry=".95"/></g>`, ''],
945} as const
946// What the terminal presses in place of a control's icon.
947const MARKS = { investigate: '✻', bell: '◉', bellOff: '◯', history: 'History' } as const
948type IconName = keyof typeof ICONS
949// Wider than any pane: an Svg with no width takes its own width up to the slot, so it fills the line.
950const HAIRLINE = `<svg xmlns="http://www.w3.org/2000/svg" width="4000" height="1"><rect width="4000" height="1" fill="${'#8A8A8A'}" fill-opacity="0.35"/></svg>`
951const BADGES = {
952 approved: `<circle cx="15" cy="15" r="4.5" fill="${'#2F9E44'}" stroke="#fff" stroke-width="1"/><path d="M13 15.2l1.4 1.4 2.6-2.8" fill="none" stroke="#fff" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>`,
953 changes: `<circle cx="15" cy="15" r="4.5" fill="${'#D0485C'}" stroke="#fff" stroke-width="1"/><path d="M13.4 13.4l3.2 3.2M16.6 13.4l-3.2 3.2" stroke="#fff" stroke-width="1.4" stroke-linecap="round"/>`,
954 pending: '',
955} as const
956const avatarSvg = (login: string, review: keyof typeof BADGES, picture?: string) =>
957 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><defs><clipPath id="c"><circle cx="9" cy="9" r="8"/></clipPath></defs>` +
958 (picture
959 ? `<image href="${picture}" x="1" y="1" width="16" height="16" clip-path="url(#c)"/>`
960 : `<circle cx="9" cy="9" r="8" fill="#8A8A8A"/><text x="9" y="12.5" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#fff">${login.slice(0, 1).toUpperCase()}</text>`) +
961 `${BADGES[review]}</svg>`
962const svg = (name: IconName, tone: Tone) =>
963 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">${ICONS[name][0].replaceAll('"C"', `"${TONES[tone][0]}"`)}</svg>`
964
965const REVIEW = {
966 APPROVED: ['check', 'success', 'approved'],
967 CHANGES_REQUESTED: ['cross', 'error', 'changes requested'],
968 REVIEW_REQUIRED: ['ring', 'warning', 'waiting for review'],
969} as const
970const CI = { passing: ['dot', 'success', 'ci ok'], failing: ['dot', 'error', 'ci failing'], running: ['dot', 'warning', 'ci running'] } as const
971// What the terminal writes for a mark; empty means the mark is only decoration there.
972const WORDS: Record<IconName, string> = {
973 dot: '', check: 'approved', cross: 'changes', ring: 'review', draft: 'draft', merged: 'merged', closed: 'closed',
974 spark: 'new', warning: '', bell: '', bellOff: '', investigate: '', comment: '', error: '', logs: '', metrics: '', traces: '', history: '', group: '', list: '', schedule: '', pause: '', play: '',
975}
976
977const review = (pr: PullRequest) =>
978 pr.state === 'MERGED' ? (['merged', 'merged', 'merged'] as const)
979 : pr.state === 'CLOSED' ? (['closed', 'muted', 'closed'] as const)
980 : pr.draft ? (['draft', 'muted', 'draft'] as const) : pr.review ? REVIEW[pr.review] : null
981
982export const register: Register = on => {
983
984 on('session.start', async ($, e, next) => {
985 await $.command.register({ name: 'watch', description: 'Show your services and pull requests' })
986 // State outlives a reload but the run that set this flag doesn't: left set, Refresh would never come back.
987 await update($, running, () => null)
988 // Runs land in the database from launchd; read the newest while the session runs.
989 $.clock.every(60_000, () => load($))
990 // No pane is open to say what failed; /watch reads again and toasts it.
991 await ready($).catch(() => {})
992
993 return next(e)
994 })
995
996 // A desktop app attaching reads the newest run; the panes wait for /watch.
997 on('session.attach', async ($, e, next) => {
998 const attached = await next(e)
999 await ready($).catch(() => {})
1000
1001 return attached
1002 })
1003
1004 on('command.run', { command: 'watch' }, async $ => {
1005 await show($)
1006
1007 return { text: 'On my watch opened.' }
1008 })
1009
1010 on('ui.render', { component: 'Pane', requestId: PANES.watch.id }, async ($, e) => {
1011 const ui = $.ui.resolve(e)
1012 const { Box, Text, Button, Link } = ui
1013 const look = surfaceLook(ui, e.surface)
1014 const picker = pickerFor(ui, e.surface)
1015 const latestRun = await read($, result)
1016 const busy = await read($, running)
1017 const view = await read($, watchView)
1018 const since = await read($, runningSince)
1019 const refresh = (part: Part | 'all') => refreshControl(ui, look, $, part, busy, since)
1020 // A full button, not plain: the desktop draws a plain one with a hotkey as a hint, easy to miss as clickable.
1021 const back = <Button key="latest" variant="secondary" hotkey={look.hotkey('l')} label="← Latest" onPress={() => update($, watchView, () => 'latest')} />
1022
1023 if (!latestRun) {
1024 return (
1025 <Box flexDirection="column" gap={1}>
1026 <Text dimColor>No runs yet. Put your config in ~/.on-my-watch/config.json, then check.</Text>
1027 <Box flexDirection="row">{refresh('all')}</Box>
1028 </Box>
1029 )
1030 }
1031
1032 if (view === 'history') {
1033 const past = await read($, runs)
1034 return (
1035 <Box flexDirection="column">
1036 <Box flexDirection="row" justifyContent="space-between"><Text bold>History</Text>{back}</Box>
1037 {past.length === 0 && <Text dimColor>No runs yet.</Text>}
1038 {/* Grouped by day; each run: when, the window it checked, then what it found. */}
1039 {past.flatMap((run, i) => [
1040 ...(i === 0 || day(run.checkedAt) !== day(past[i - 1]!.checkedAt)
1041 ? [<Box key={`day:${day(run.checkedAt)}`} marginTop={1}><Text bold dimColor>{day(run.checkedAt)}</Text></Box>]
1042 : [look.divider(`sep:run:${run.id}`)]),
1043 // Columns that line up down the list: when and the window, then each priority's count.
1044 <Box key={`row:${run.id}`} flexDirection="row" columnGap={1} alignItems="center">
1045 <Box key={`col:when:${run.id}`} width={HISTORY_WHEN_WIDTH} flexShrink={0}>
1046 <Button key={`run:${run.id}`} plain onPress={() => openRun($, run.id)}>
1047 {`${time(run.checkedAt)}${run.span ? ` · ${window(run.span)}` : ''}`}
1048 </Button>
1049 </Box>
1050 {run.status === 'failed'
1051 // What went wrong, as the checker kept it: the fix it needs and what pup or gh said.
1052 ? <Box key={`reason:${run.id}`} flexDirection="row" columnGap={1} minWidth={0} flexShrink={1}>
1053 <Text color="error">check failed</Text>
1054 {run.reason && <Text dimColor wrap="truncate-end">{run.reason}</Text>}
1055 </Box>
1056 : !run.problems
1057 ? <Text color="success">all clear</Text>
1058 : [
1059 // How many services each priority had failing; the words go to screen readers.
1060 ...[1, 2, 3].map(rank => {
1061 const n = (run.labels ?? []).filter(l => l.priority === rank).reduce((sum, l) => sum + l.count, 0)
1062 return (
1063 <Box key={`col:p${rank}:${run.id}`} width={HISTORY_COUNT_WIDTH} flexShrink={0} flexDirection="row" alignItems="center">
1064 {n > 0 && [serviceIcon(ui, look, rank, n), <Text key="n">{String(n)}</Text>]}
1065 </Box>
1066 )
1067 }),
1068 ]}
1069 </Box>,
1070 ])}
1071 </Box>
1072 )
1073 }
1074
1075 const stored = view === 'archived' ? ((await read($, archived)) ?? latestRun) : latestRun
1076 const [chosenPriority, chosenName, chosenEnv] = [await read($, priorityFilter), await read($, nameFilter), await read($, envFilter)]
1077 const known = await read($, choices)
1078 // Runs stored before entries had names carry service instead; they show under it until refreshed.
1079 const named = stored.problems.map(p => ({ ...p, name: p.name ?? (p as any).alias ?? (p as any).service }))
1080 const r = {
1081 ...stored,
1082 problems: named
1083 .filter(p => !chosenPriority || nameOf(p) === chosenPriority)
1084 .filter(p => !chosenName || p.name === chosenName)
1085 .filter(p => !chosenEnv || p.env === chosenEnv || (known.envGroups?.[chosenEnv] ?? []).includes(p.env)),
1086 }
1087 // The config's choices; before the config is read, those this run found.
1088 // Narrow panes put each row's facts under its label; the width is in character cells.
1089 const columns = (e.props as { bodyColumns?: number }).bodyColumns ?? 100
1090 const narrow = columns < NARROW
1091 // Wide rows also say since when and what they read; below that the label keeps the room.
1092 const wide = columns >= WIDE
1093 const quietOnes = await read($, quietEntries)
1094 const listed = <T,>(fromConfig: T[], found: T[]) => (fromConfig.length ? fromConfig : [...new Set(found)])
1095 const priorityOptions = [{ value: '', label: narrow ? 'Priority' : 'All priorities' }, ...listed(known.priorities, named.map(nameOf)).map(p => ({ value: p, label: p }))]
1096 const nameOptions = [{ value: '', label: narrow ? 'Repository' : 'All repositories' }, ...listed(known.names, named.map(p => p.name)).map(n => ({ value: n, label: n }))]
1097 const envOptions = [{ value: '', label: narrow ? 'Env' : 'All environments' }, ...listed(known.envs, named.map(p => p.env)).map(n => ({ value: n, label: n }))]
1098
1099 // One card per name and environment; the most urgent first, a query without priority after all that have one.
1100 const rank = (p: Problem) => (p.priority == null ? Number.MAX_SAFE_INTEGER : p.priority)
1101 const cards: Problem[][] = [...r.problems.reduce((all, p) => all.set(`${p.name}@${p.env}`, [...(all.get(`${p.name}@${p.env}`) ?? []), p]), new Map<string, Problem[]>()).values()]
1102 .map(list => [...list].sort((a, b) => rank(a) - rank(b)))
1103 cards.sort((a, b) => rank(a[0]!) - rank(b[0]!) || a[0]!.name.localeCompare(b[0]!.name))
1104 // The history column fits the widest history on screen: every row lines up, no cell is left blank for a
1105 // longer one that isn't there.
1106 const historyCells = Math.max(3, ...r.problems.map(p => {
1107 const said = sinceText(p).length
1108 const drawn = charted(p) ? (look.isTerminal ? fromStart(p.trend!.values).length : CHART_CELLS) : 0
1109 return p.trend?.isNew ? 3 : drawn + (drawn && said ? 1 : 0) + said
1110 }))
1111 const pausedAt = (await read($, scheduleState))?.paused
1112 const controls = [
1113 pausedNote(ui, look, pausedAt),
1114 wide || look.isTerminal
1115 ? (
1116 <Box key="history-row" flexDirection="row" alignItems="center">
1117 {look.icon('history', 'muted', 'History')}
1118 <Button key="history" plain dimColor hotkey={look.hotkey('h')} label="History" onPress={() => openHistory($)} />
1119 </Box>
1120 )
1121 : look.control('history', 'history', 'History', () => openHistory($)),
1122 ]
1123 // The window is picked in the header, so the filters fit one line; picking one checks it right away.
1124 // The config's choice names the config's window; the last run's may be another, picked earlier.
1125 const configured = ((await read($, configDoc)) as any)?.window as string | undefined
1126 const windows = WINDOWS.map(o => (o.value === '' && configured ? { ...o, label: `Config: ${windowLabel(configured)}` } : o))
1127 const windowPicker = picker('window', 't', windows, await read($, watchWindow), async v => { await update($, watchWindow, () => v); await checkNow($, 'watch') })
1128 const checked = r.checkedAt ? (wide ? ` · ${refreshed(r.checkedAt)}` : ` · ${when(r.checkedAt)}`) : ''
1129
1130 return (
1131 <Box flexDirection="column">
1132 {r.setup?.filter(s => s.tool === 'pup').map(s => (
1133 <Box key={`setup:${s.tool}`} flexDirection="row" flexWrap="wrap" columnGap={2} alignItems="center">
1134 <Text color="warning">{s.fix}</Text>
1135 {s.need === 'login' && <Button key="signin:pup" variant="secondary" label="Sign in" onPress={() => signIn($)} />}
1136 </Box>
1137 ))}
1138 {view === 'archived'
1139 ? (
1140 <Box key="archived" flexDirection="row" justifyContent="space-between" columnGap={1}>
1141 <Text dimColor>{`Run ${r.checkedAt ? time(r.checkedAt) : ''}${r.span ? ` · ${window(r.span)}` : ''}`}</Text>
1142 {back}
1143 </Box>
1144 )
1145 : (
1146 // Line one: what the last check found, History and Refresh; line two: what to show and check.
1147 <Box key="watch" flexDirection="column">
1148 <Box key="header" flexDirection="row" flexWrap="wrap" justifyContent="space-between" alignItems="center" columnGap={2}>
1149 <Box flexDirection="row" flexShrink={1} minWidth={0} columnGap={1} alignItems="center" flexWrap="wrap">
1150 {!r.checkedAt
1151 ? <Text dimColor>not checked yet</Text>
1152 : r.status === 'failed'
1153 ? <Text color="error" bold>check failed</Text>
1154 : r.problems.length
1155 // How many services each priority has failing, the most urgent first.
1156 ? <Box key="counts" flexDirection="row" columnGap={2} alignItems="center">
1157 {[1, 2, 3, null].map(rank => [rank, r.problems.filter(p => (p.priority ?? null) === rank).length] as const)
1158 .filter(([, n]) => n > 0)
1159 .map(([rank, n]) => (
1160 <Box key={`count:${rank}`} flexDirection="row" alignItems="center">
1161 {serviceIcon(ui, look, rank, n)}<Text bold>{String(n)}</Text>
1162 </Box>
1163 ))}
1164 </Box>
1165 : <Text dimColor>Checked</Text>}
1166 {windowPicker}
1167 <Text dimColor>{checked}</Text>
1168 </Box>
1169 <Box flexDirection="row" columnGap={2} alignItems="center" flexShrink={0} flexGrow={1} justifyContent="flex-end">
1170 {controls}
1171 {refresh('watch')}
1172 </Box>
1173 </Box>
1174 {look.divider('sep:watch')}
1175 <Box key="filters" flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1} alignItems="center">
1176 {picker('priorities', 'p', priorityOptions, chosenPriority, v => update($, priorityFilter, () => v))}
1177 {picker('names', 'v', nameOptions, chosenName, v => update($, nameFilter, () => v))}
1178 {picker('envs', 'e', envOptions, chosenEnv, v => update($, envFilter, () => v))}
1179 </Box>
1180 </Box>
1181 )}
1182
1183 {/* Nothing crossed a line: say so in the middle of where the cards would be. */}
1184 {r.checkedAt && r.status !== 'failed' && cards.length === 0 && (
1185 <Box key="clear" flexDirection="column" alignItems="center" marginY={2}>
1186 <Box flexDirection="row" columnGap={1} alignItems="center">
1187 {look.icon('check', 'success', 'all clear')}
1188 <Text color="success" bold>All clear</Text>
1189 </Box>
1190 <Text dimColor>No query crossed its alert line</Text>
1191 </Box>
1192 )}
1193 {cards.map(list => {
1194 const first = list[0]!
1195 const id = `card:${first.name}@${first.env}`
1196 return (
1197 // A card: name and environment under its most urgent priority, then one row per query over its line.
1198 <Box key={id} flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1} marginTop={1}>
1199 {/* Top-aligned: a title that wraps keeps its priority beside its first line. */}
1200 <Box key={`card-head:${first.name}@${first.env}`} flexDirection="row" columnGap={1} alignItems="flex-start">types/index.d.ts 154 lines1// What bin/on_my_watch.py stores per run and prints with `latest`; the pane only reads it.
2
3/** A query of a named entry that crossed its alert line in the checked window. */
4export type Problem = {
5 /** The repository's name from the config, such as Checkout. */
6 name: string
7 /** The repository, owner/name. */
8 repo?: string | null
9 /** The service in that repository, as the config names it. */
10 service?: string
11 /** The query's own env, for display; the query itself says which data it reads. */
12 env: string
13 label: string
14 /** Rank: 1 is the most urgent. */
15 priority?: number | null
16 /** The priority as the config names it: high, or P1 when priorities are numbers. */
17 priorityName?: string
18 source: 'logs' | 'traces' | 'metrics'
19 value: string
20 /** The alert line it crossed, in words: `above 19`. */
21 alert?: string
22 /** The query in Datadog, pinned to the checked window. */
23 link: string
24 query: string
25 /** The config's notify for this query; false keeps it in the pane only. */
26 notify?: boolean
27 /** The count as measured; runs stored before it was kept have none. */
28 raw?: number | null
29 /** The alert line as a number, when the query alerts above one. */
30 threshold?: number | null
31 /** The last runs, worked out when the newest is read. */
32 trend?: {
33 /** Oldest first; null where the entry was under its line. */
34 values: (number | null)[]
35 /** When its current unbroken stretch over the line began. */
36 since: string | null
37 previous: number | null
38 /** The run before didn't have it. */
39 isNew: boolean
40 }
41}
42
43export type PullRequest = {
44 /** Its size: files changed, lines added and taken out. Older runs have none. */
45 files?: number | null
46 additions?: number | null
47 deletions?: number | null
48 /** It conflicts with its base; behind it. */
49 conflicts?: boolean
50 behind?: boolean
51 /** Review threads not yet resolved. */
52 threads?: number
53 /** The names of the entries whose repo this is. */
54 name?: string
55 /** Older runs only, before entries had names. */
56 service?: string
57 alias?: string | null
58 repo: string
59 number: number
60 title: string
61 author: string | null
62 /** The author is a bot account, such as dependabot. */
63 bot: boolean
64 draft: boolean
65 state: 'OPEN' | 'MERGED' | 'CLOSED'
66 /** All checks in one state; null when the PR has none. */
67 ci?: 'passing' | 'failing' | 'running' | null
68 review: 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED' | null
69 createdAt: string
70 /** Last activity; the list is sorted and filtered by it. */
71 updatedAt: string
72 /** Closed and merged ones: when; a later comment moves updatedAt, not this. */
73 closedAt?: string | null
74 /** Who reviewed or is asked to: approved, asked for changes, or still pending. */
75 reviewers?: { login: string; review: 'approved' | 'changes' | 'pending' }[]
76 url: string
77}
78
79export type Part = 'watch' | 'pull_requests'
80
81export type Result = {
82 /** When observability last ran; null until it has. */
83 checkedAt: string | null
84 window: string
85 /** The exact window the watch run counted; every link of the run is pinned to it. */
86 span?: { from: string; to: string }
87 status: 'ok' | 'failed'
88 errors: string[]
89 problems: Problem[]
90 pullRequests: PullRequest[]
91 /** When pull requests were last fetched; null until they have been. */
92 prsCheckedAt: string | null
93 /** Who the GitHub CLI is signed in as: whose review a PR waits on. */
94 viewer?: string | null
95 prErrors: string[]
96 /** What the user must do first: install or sign in to pup or gh. */
97 setup: { tool: 'pup' | 'gh'; need?: 'install' | 'login'; fix: string }[]
98}
99
100/** One past watch run, from the checker's `history`. */
101export type RunSummary = {
102 id: number; checkedAt: string; status: 'ok' | 'failed'; span?: { from: string; to: string }
103 /** Why a failed run failed: the fix it needed and what pup or gh said; absent for an ok run. */
104 reason?: string | null
105 /** What it found: problems, and how many per label (most urgent first). */
106 problems: number; labels?: { label: string; count: number; priority: number | null; priorityName: string }[]
107}
108
109declare module 'claude-code' {
110 interface PluginState {
111 'on-my-watch': { result: Result | null; running: Part | 'all' | null
112 runningSince: string | null; span: string; hideBots: boolean; prState: 'open' | 'merged' | 'closed'
113 /** The page of pull requests shown, from 1. */
114 prPage: number
115 priorityFilter: string
116 nameFilter: string
117 envFilter: string
118 /** What the Watch dropdowns offer, from the config's entries and queries. */
119 choices: { names: string[]; envs: string[]; envGroups: Record<string, string[]>; priorities: string[]; bots: string[] }
120 /** Entries whose notifications are turned off from the pane, as `repo name|service|env|label`. */
121 quietEntries: string[]
122 groupPrs: boolean
123 watchView: 'latest' | 'history' | 'archived'
124 /** The config as the Config pane edits it; when it was read, and the schema's objections to it. */
125 configDoc: Record<string, any> | null
126 configReadAt: string
127 configProblems: string[]
128 /** Why the last change was refused; empty when it saved. */
129 configError: string
130 /** Settings typed but not saved yet, by path. */
131 settingsDraft: Record<string, string> | null
132 /** The environment groups as typed, [name, comma-separated members] row by row; null until one changes. */
133 envRows: [string, string][] | null
134 /** The environment group being added. */
135 envDraft: { name: string; members: string }
136 /** The config with the Config pane's unsaved changes; null when it has none. */
137 configDraft: Record<string, any> | null
138 runs: RunSummary[]
139 archived: Result | null
140 watchWindow: string
141 /** Reviewers' pictures as data URLs, by login: an Svg can't load one from the web. */
142 avatars: Record<string, string>
143 /** Notifications are muted: a flag file the checker reads, so scheduled runs stay quiet too. */
144 muted: boolean
145 /** Scheduled runs: paused since when (null while on), the general cron line, the next run, and queries with their own. */
146 scheduleState: { paused: string | null; schedule: string; next: string | null; own: { entry: string; schedule: string }[] } | null
147 /** The picker showing a Custom input: '' for the general schedule, else a query's entry; null for none. */
148 customFor: string | null
149 /** The Custom input's text, for Save. */
150 cronDraft: string
151 }
152 }
153}
154