Opens Bad Time Simulator (a fan game by Jcw87, recreating the Sans fight from UNDERTALE by Toby Fox) in its own window while Claude works. Not affiliated with…

A Claude Code mod that opens Bad Time Simulator in its own window while Claude works, so you can dodge bones instead of watching a spinner.
The game is not mine. Bad Time Simulator is a free fan game by Jcw87 (source), recreating the Sans fight from UNDERTALE by Toby Fox. This mod only opens Jcw87's public page in a browser window; it contains no game files, art or music, and is not affiliated with or endorsed by either of them. See Credits.
Needs Claude Code 2.1.287 or later and Chrome, Edge, Chromium or Brave.
/plugin marketplace add FatihBastan/bad-time
/plugin install bad-time@bad-time
To try it from a folder without installing:
claude --plugin-dir ./bad-time
| Command | What it does |
|---|---|
/badtime or /badtime on | Turns it on (remembered across sessions) |
/badtime off | Turns it off and closes the window |
/badtime now | Opens the game, or brings a paused one back, even when Claude is idle |
/badtime close | Closes the window; it opens again the next time Claude works |
/badtime status | Shows whether it is on, the window, the browser and the URL |
Change these in /config or with /plugin configure bad-time@bad-time.
| Setting | Default | Notes |
|---|---|---|
onFinish | pause | pause keeps one fight for the whole session and brings it back to the front on your next prompt (closes instead on Linux, where the fight can't be frozen); close closes the window after Claude answers and starts a fresh fight every prompt |
delaySeconds | 2 | 0 to 30; shorter turns never open the window |
countdownSeconds | 3 | 0 to 10; with close, seconds between the chime and the window closing |
resumeDelaySeconds | 2.5 | 0 to 10; how long the fight stays frozen after you click back into the game |
sounds | on | The ask and done chimes |
strayKeyGuard | on | Holds a prompt sent within 1.5 s of the mod closing the window and puts it back in the box |
windowSize | auto | Or WIDTHxHEIGHT, such as 900x700, 320 to 3840 each |
browserPath | empty | Full path to a Chromium-based browser; empty means find one |
url | Bad Time Simulator | Must be https://; point it at a mirror if the page goes down |
Invalid values fall back to the default, with one toast naming the setting.
On a Mac, quit the game with ⌘Q or /badtime close: the red close button leaves the browser running with no window, so the mod can't tell it's gone.
strayKeyGuard). If the mod fails, Claude carries on as if it were not installed.~/.bad-time/profiles/slot-N, with no logins, cookies or extensions, separate from your normal browser.scripts/, run by its absolute path, without a shell and without inline or encoded code, from your home folder.| Program | When | Why |
|---|---|---|
Chrome, Edge, Chromium or Brave, found at its usual install path (or browserPath) | Claude has worked for 2 s | Shows the game in its own window and profile (--app, --user-data-dir=~/.bad-time/profiles/slot-N) |
Windows: powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File scripts/windows-launcher.ps1 | With the game | Starts the browser with Start-Process; brings the game to the front on your next prompt; freezes the game's own page process (NtSuspendProcess) while its window isn't in front and resumes it after the countdown; draws the countdown and the CLAUDE NEEDS YOU / CLAUDE IS DONE banner; flashes Claude's taskbar button when Claude needs you. Bypass applies to this one process, so its own files run without changing your machine's policy |
Windows: scripts/windows-watchdog.ps1, started hidden by the launcher | With the game | Closes the game window if the launcher is gone (Claude exited) |
Windows: scripts/windows-processes.ps1, then taskkill.exe | At session start and when closing | Finds game windows left by a crashed session, by their bad-time profile folder, and closes them |
Windows: scripts/windows-screen.ps1, scripts/windows-chime.ps1 | Opening the game; a chime | Reads the screen size to center the window; plays sounds/ask.wav or sounds/done.wav |
macOS: /bin/ps, /bin/kill | While the game is open | Finds the game's own processes by their profile folder; kill -STOP / -CONT freeze and resume the game's page while you're away; -TERM / -KILL close it |
macOS: osascript -l JavaScript scripts/mac-front.js, mac-screen.js, mac-show.js | Twice a second while the game is open; opening it; your next prompt | Which app is in front; the screen size; bring the game back to the front |
Linux: ps, kill, paplay or aplay | As above | Find and close the game; play a chime |
url setting, and whatever that page itself loads. It runs in a fresh profile with no logins or cookies. Nothing about you, your session, your prompts or your code is sent anywhere.HOME, PATH (absolute entries only), OS, USERPROFILE, SystemRoot, ProgramFiles, ProgramFiles(x86), LOCALAPPDATA, only to find your home folder, the browser and system tools.scripts/ and sounds/. Writes only under ~/.bad-time/: the browser profiles, and on Windows control/slot-N.txt, the switch the launcher reads (show, ask, done).| Hook | What it does | Changes the event? |
|---|---|---|
session.start | Registers /badtime, closes leftover game windows of crashed sessions | No, returns next(e) |
turn.start | Opens the game, or brings it back, once Claude has worked 2 s | No |
turn.complete | Done chime, "Claude is done." notice, banner; in close mode closes the window after the countdown | No |
classic.PermissionRequest | Ask chime, "Claude needs you." notice, banner. Never decides the permission | No, always return next(e) |
tool.call (AskUserQuestion) | The same alert. Never answers the question | No, always return next(e) |
prompt.submit | Stray-key guard: a prompt typed by you within 1.5 s of the mod closing the game window goes back into the prompt box unsent | Only that case; off with strayKeyGuard |
session.end | Closes the game | No |
command.run (badtime) | Answers its own /badtime command | Only its own command |
bad-time collects nothing. It has no server, no analytics and no account. It does not read or send your prompts, code, files or Claude's replies. The only network traffic is the game page loading in its own browser window, from Jcw87's GitHub Pages site (or the url you set); that site's own privacy terms apply to that page. Everything the mod stores stays on your computer, under ~/.bad-time/ and in Claude Code's plugin store, and is removed with the plugin and that folder.
claude plugin validate --strict .
claude plugin test .
Support and bug reports: GitHub Issues.
MIT for this mod's own code and its generated chimes (see LICENSE). Bad Time Simulator is Jcw87's; UNDERTALE, its characters and its music are Toby Fox's. This repository contains none of their files.
hooks/register.ts 1062 lines1// bad-time: opens Bad Time Simulator (by Jcw87) in its own browser window while
2// Claude works. Not affiliated with UNDERTALE or Toby Fox.
3// Every hook passes Claude's events on untouched; the only exception is the
4// opt-out stray-key guard on the user's own prompt.
5
6import type {
7 EngineInterface,
8 HookStream,
9 PluginOptions,
10 ProcessSpawnChunk,
11 ProcessSpawnResult,
12 Register,
13 Timer,
14} from 'claude-code'
15
16type Api = EngineInterface
17type Os = 'mac' | 'windows' | 'linux'
18type Mode = 'pause' | 'close'
19
20type Config = {
21 url: string
22 delayMs: number
23 mode: Mode
24 countdownMs: number
25 resumeMs: number
26 sounds: boolean
27 strayKeyGuard: boolean
28 windowSize: string
29 browserPath: string
30}
31
32type Sys = {
33 os: Os
34 home: string
35 sep: '/' | '\\'
36 ps?: string
37 kill?: string
38 osascript?: string
39 player?: string
40 powershell?: string
41 taskkill?: string
42}
43
44type Win = {
45 stream: HookStream<ProcessSpawnChunk, ProcessSpawnResult>
46 pid?: number
47 slot: number
48 startedAt: number
49 closing: boolean
50 ended: boolean
51 hidden: boolean
52 control?: string
53 seq: number
54 focus?: Timer
55 focusBusy?: boolean
56 frozen?: number[]
57 wasFront?: boolean
58 resumeAt?: number
59 heartbeat?: Timer
60}
61
62type SlotRecord = { owner: string; beat: number }
63
64const DEFAULT_URL = 'https://jcw87.github.io/c2-sans-fight/'
65const SLOT_COUNT = 4
66const HEARTBEAT_MS = 15_000
67const STALE_MS = 60_000
68const STRAY_MS = 1_500
69const CHIME_GAP_MS = 1_500
70const QUICK_EXIT_MS = 3_000
71const CLOSE_GRACE_MS = 3_000
72const USAGE = 'Usage: /badtime [on | off | now | close | status]'
73
74const INSTANCE = makeId()
75
76let cfg: Config = readConfig({}, [])
77let problems: string[] = []
78let enabled = true
79let turnGen = 0
80let activeTurn: string | undefined
81let openTimer: Timer | undefined
82let countdown: Timer | undefined
83let win: Win | undefined
84let launching: Promise<void> | undefined
85let closedAt = 0
86const lastChime: { ask?: number; done?: number } = {}
87let sysPromise: Promise<Sys | undefined> | undefined
88let browserFound: string | undefined
89let screenPromise: Promise<{ w: number; h: number } | undefined> | undefined
90const warned = new Set<string>()
91
92export const register: Register = (on, options) => {
93 problems = []
94 cfg = readConfig(options, problems)
95
96 on('session.start', async ($, e, next) => {
97 try {
98 await $.command.register({
99 name: 'badtime',
100 description: 'Bad Time Simulator while Claude works (on, off, now, close, status)',
101 argumentHint: '[on|off|now|close|status]',
102 immediate: true,
103 })
104 enabled = (await $.store.get('enabled')) !== false
105 for (const problem of problems) warnOnce($, `config:${problem}`, `${problem}; using the default`)
106 void reapStale($)
107 } catch (err) {
108 debug($, `session.start: ${describe(err)}`)
109 }
110 return next(e)
111 })
112
113 on('command.run', { command: 'badtime' }, async ($, e) => {
114 try {
115 return { text: await runCommand($, e.args) }
116 } catch (err) {
117 debug($, `command: ${describe(err)}`)
118 return { text: `bad-time: ${describe(err)}` }
119 }
120 })
121
122 on('turn.start', async ($, e, next) => {
123 try {
124 turnGen += 1
125 activeTurn = e.turnId
126 countdown?.cancel()
127 countdown = undefined
128 openTimer?.cancel()
129 openTimer = undefined
130 const live = win !== undefined && !win.closing
131 if (enabled && (!live || win?.hidden === true)) {
132 const gen = turnGen
133 openTimer = $.clock.after(Math.max(1, cfg.delayMs), () => {
134 openTimer = undefined
135 if (gen === turnGen && activeTurn !== undefined) void openForTurn($, gen)
136 })
137 }
138 } catch (err) {
139 debug($, `turn.start: ${describe(err)}`)
140 }
141 return next(e)
142 })
143
144 on('turn.complete', async ($, e, next) => {
145 try {
146 if (e.agentId === undefined && e.turnId === activeTurn) {
147 activeTurn = undefined
148 openTimer?.cancel()
149 openTimer = undefined
150 const isShown = win !== undefined && !win.closing && !win.hidden
151 if (isShown && !e.isAborted) {
152 void chime($, 'done')
153 notice($, 'Claude is done.')
154 void signalGame($, 'done')
155 }
156 const closes = cfg.mode === 'close' || (await system($))?.os === 'linux'
157 if (closes && (isShown || launching !== undefined)) {
158 const gen = turnGen
159 countdown?.cancel()
160 countdown = $.clock.after(Math.max(1, cfg.countdownMs), () => {
161 countdown = undefined
162 if (gen !== turnGen || activeTurn !== undefined) return
163 void closeWindow($, true)
164 })
165 } else if (win !== undefined && !win.closing) {
166 win.hidden = true
167 }
168 }
169 } catch (err) {
170 debug($, `turn.complete: ${describe(err)}`)
171 }
172 return next(e)
173 })
174
175 on('classic.PermissionRequest', ($, e, next) => {
176 alertIfOpen($).catch(() => undefined)
177 return next(e)
178 }).catch((_$, e, next) => next(e))
179
180 on('tool.call', { tool: 'AskUserQuestion' }, ($, e, next) => {
181 alertIfOpen($).catch(() => undefined)
182 return next(e)
183 }).catch((_$, e, next) => next(e))
184
185 on('prompt.submit', async ($, e, next) => {
186 try {
187 if (
188 cfg.strayKeyGuard &&
189 closedAt > 0 &&
190 e.origin?.kind === 'composer' &&
191 (e.attachments === undefined || e.attachments.length === 0) &&
192 (await $.clock.now()) - closedAt < STRAY_MS
193 ) {
194 closedAt = 0
195 const text = e.text
196 $.clock.after(100, () => void refill($, text))
197 return {
198 drop: 'bad-time held this prompt: it was sent just as the game window closed. It is back in the prompt box; press Enter to send it.',
199 }
200 }
201 } catch (err) {
202 debug($, `stray-key guard: ${describe(err)}`)
203 }
204 return next(e)
205 }).catch((_$, e, next) => next(e))
206
207 on('session.end', async ($, e, next) => {
208 try {
209 if (e.reason !== 'clear' && e.reason !== 'resume') {
210 openTimer?.cancel()
211 countdown?.cancel()
212 const w = win
213 if (w !== undefined) {
214 w.closing = true
215 w.heartbeat?.cancel()
216 if (w.pid !== undefined) void endWindowsBrowser($, w.pid)
217 w.focus?.cancel()
218 void endMacFreeze($, w)
219 stopStream(w)
220 void releaseSlot($, w.slot)
221 }
222 }
223 } catch (err) {
224 debug($, `session.end: ${describe(err)}`)
225 }
226 return next(e)
227 })
228}
229
230async function endWindowsBrowser($: Api, pid: number): Promise<void> {
231 const sys = await system($)
232 if (sys?.os === 'windows') await windowsClose($, sys, pid, true)
233}
234
235async function runCommand($: Api, args: string): Promise<string> {
236 const verb = args.trim().toLowerCase() || 'on'
237 switch (verb) {
238 case 'on': {
239 enabled = true
240 await $.store.set('enabled', true)
241 if (activeTurn !== undefined && win === undefined) void launch($)
242 return `bad-time is on. The game opens once Claude has been working for ${seconds(cfg.delayMs)}.`
243 }
244 case 'off': {
245 enabled = false
246 await $.store.set('enabled', false)
247 openTimer?.cancel()
248 openTimer = undefined
249 countdown?.cancel()
250 countdown = undefined
251 void closeWindow($, true)
252 return 'bad-time is off. Run /badtime on to turn it back on.'
253 }
254 case 'now': {
255 if (win !== undefined && !win.closing && win.hidden) {
256 void resumeWindow($)
257 return 'Bringing the game back.'
258 }
259 if (win !== undefined && !win.closing) return 'The game window is already open.'
260 void launch($, true)
261 return 'Opening Bad Time Simulator.'
262 }
263 case 'close': {
264 if (win === undefined) return 'No game window is open.'
265 void closeWindow($, true)
266 return enabled
267 ? 'Closed the game window. It opens again the next time Claude works.'
268 : 'Closed the game window.'
269 }
270 case 'status':
271 return await statusText($)
272 default:
273 return USAGE
274 }
275}
276
277async function statusText($: Api): Promise<string> {
278 const sys = await system($)
279 const browser = sys === undefined ? undefined : await findBrowser($, sys, false)
280 const state = enabled ? 'on' : 'off'
281 const mode =
282 cfg.mode === 'close'
283 ? `closes ${seconds(cfg.countdownMs)} after Claude finishes`
284 : 'stays open between prompts and comes back to the front on your next prompt'
285 const live = win !== undefined && !win.closing ? win : undefined
286 const window = live === undefined ? 'closed' : `open (slot ${live.slot} of ${SLOT_COUNT})`
287 return [
288 `bad-time is ${state}; the window ${mode}.`,
289 `Window: ${window}. Opens after ${seconds(cfg.delayMs)} of work. Chime: ${cfg.sounds ? 'on' : 'off'}.`,
290 `Browser: ${browser ?? 'none found (set browserPath)'}.`,
291 `Game: ${cfg.url}`,
292 'Bad Time Simulator by Jcw87 (github.com/Jcw87/c2-sans-fight), a fan recreation of the Sans fight from UNDERTALE by Toby Fox. Not affiliated with either.',
293 ].join('\n')
294}
295
296async function openForTurn($: Api, gen: number): Promise<void> {
297 for (let i = 0; i < 16 && win !== undefined && win.closing; i += 1) await pause($, 250)
298 if (gen !== turnGen || activeTurn === undefined || !enabled) return
299 if (win !== undefined && !win.closing && win.hidden) {
300 await resumeWindow($)
301 return
302 }
303 await launch($)
304}
305
306async function resumeWindow($: Api): Promise<void> {
307 const w = win
308 if (w === undefined || w.closing || !w.hidden) return
309 const sys = await system($)
310 if (sys === undefined) return
311 w.hidden = false
312 if (!(await setShown($, sys, w))) {
313 await closeWindow($, false)
314 for (let i = 0; i < 16 && win !== undefined; i += 1) await pause($, 250)
315 await launch($)
316 }
317}
318
319async function setShown($: Api, sys: Sys, w: Win): Promise<boolean> {
320 try {
321 if (sys.os === 'windows') {
322 if (w.control === undefined) return false
323 w.seq += 1
324 await $.fs.write(w.control, `show ${w.seq}`)
325 return true
326 }
327 if (sys.os === 'mac' && sys.osascript !== undefined) {
328 const pid = w.pid ?? (await mainPid($, sys, w.slot))
329 if (pid === undefined) return false
330 w.pid = pid
331 const run = await $.process.run([sys.osascript, '-l', 'JavaScript', scriptFile($, sys, 'mac-show.js'), String(pid)], {
332 cwd: sys.home,
333 timeoutMs: 10_000,
334 })
335 return run.exitCode === 0 && !run.stdout.includes('missing')
336 }
337 } catch (err) {
338 debug($, `show: ${describe(err)}`)
339 }
340 return false
341}
342
343function launch($: Api, force = false): Promise<void> {
344 if (launching !== undefined) return launching
345 if (win !== undefined || (!enabled && !force)) return Promise.resolve()
346 const run = startWindow($)
347 .catch(err => debug($, `launch: ${describe(err)}`))
348 .finally(() => {
349 launching = undefined
350 if (!enabled && !force && win !== undefined) void closeWindow($, true)
351 })
352 launching = run
353 return run
354}
355
356async function startWindow($: Api): Promise<void> {
357 const surfaces = await $.session.surfaces()
358 if (!surfaces.some(s => s === 'terminal' || s === 'desktop')) return
359 const sys = await system($)
360 if (sys === undefined) {
361 warnOnce($, 'system', 'could not tell which system this is, so no game window')
362 return
363 }
364 const browser = await findBrowser($, sys, true)
365 if (browser === undefined) {
366 warnOnce($, 'browser', 'no Chrome, Edge, Chromium or Brave found; set browserPath in /config')
367 return
368 }
369 const slot = await claimSlot($)
370 if (slot === undefined) {
371 warnOnce($, 'slots', `all ${SLOT_COUNT} game windows are in use by other Claude sessions`)
372 return
373 }
374 await killSlot($, sys, slot)
375 const geo = await geometry($, sys)
376 const argv = [
377 browser,
378 `--app=${cfg.url}`,
379 `--user-data-dir=${profileDir(sys, slot)}`,
380 `--window-size=${geo.w},${geo.h}`,
381 ...(geo.x !== undefined && geo.y !== undefined ? [`--window-position=${geo.x},${geo.y}`] : []),
382 '--no-first-run',
383 '--no-default-browser-check',
384 '--hide-crash-restore-bubble',
385 '--disable-extensions',
386 '--disable-sync',
387 '--disable-hang-monitor',
388 ]
389 const control = sys.os === 'windows' ? [sys.home, '.bad-time', 'control', `slot-${slot}.txt`].join(sys.sep) : undefined
390 if (control !== undefined) await $.fs.write(control, 'show 0')
391 const request = sys.os === 'windows' && control !== undefined ? windowsLaunch(scriptFile($, sys, 'windows-launcher.ps1'), sys, argv, control) : { argv, cwd: sys.home }
392 if (request === undefined) {
393 warnOnce($, 'launcher', 'could not build the Windows launch command; check browserPath in /config')
394 await releaseSlot($, slot)
395 return
396 }
397 const w: Win = {
398 stream: $.process.spawn(request),
399 slot,
400 startedAt: await $.clock.now(),
401 closing: false,
402 ended: false,
403 hidden: false,
404 control,
405 seq: 0,
406 }
407 win = w
408 w.heartbeat = $.clock.every(HEARTBEAT_MS, () => void beat($, slot))
409 watchFocus($, sys, w)
410 void watch($, w)
411}
412
413async function watch($: Api, w: Win): Promise<void> {
414 let failedToStart = false
415 let pieces = 0
416 let out = ''
417 try {
418 for await (const chunk of w.stream) {
419 pieces += 1
420 if (w.pid === undefined && chunk.stream === 'stdout') {
421 out = (out + chunk.text).slice(-200)
422 const m = /bad-time-pid (\d{1,10})/.exec(out)
423 if (m !== null && Number.isSafeInteger(Number(m[1]))) w.pid = Number(m[1])
424 }
425 if (pieces <= 3) debug($, `browser said: ${chunk.text.slice(0, 200)}`)
426 }
427 } catch (err) {
428 failedToStart = pieces === 0
429 debug($, `browser stream: ${describe(err)}`)
430 }
431 w.ended = true
432 w.heartbeat?.cancel()
433 if (win === w) win = undefined
434 try {
435 await releaseSlot($, w.slot)
436 const livedMs = (await $.clock.now()) - w.startedAt
437 if (failedToStart) warnOnce($, 'start', 'the browser would not start; check browserPath in /config')
438 else if (!w.closing && livedMs < QUICK_EXIT_MS) warnOnce($, 'quick', 'the game window closed right after it opened')
439 } catch (err) {
440 debug($, `after the window closed: ${describe(err)}`)
441 }
442}
443
444async function closeWindow($: Api, byMod: boolean): Promise<void> {
445 if (launching !== undefined) await launching
446 const w = win
447 if (w === undefined || w.closing) return
448 w.closing = true
449 if (byMod) closedAt = await $.clock.now()
450 const sys = await system($)
451 if (sys?.os === 'windows') {
452 if (w.pid !== undefined) void windowsClose($, sys, w.pid, false)
453 } else {
454 w.focus?.cancel()
455 if (sys?.os === 'mac') await thawPage($, sys, w)
456 stopStream(w)
457 }
458 $.clock.after(CLOSE_GRACE_MS, () => {
459 if (w.ended) return
460 void forceClose($, w)
461 })
462}
463
464async function windowsClose($: Api, sys: Sys, pid: number, force: boolean): Promise<void> {
465 try {
466 if (sys.taskkill === undefined || !Number.isSafeInteger(pid) || pid <= 1) return
467 const args = force ? ['/PID', String(pid), '/T', '/F'] : ['/PID', String(pid), '/T']
468 await $.process.run([sys.taskkill, ...args], { cwd: sys.home })
469 } catch (err) {
470 debug($, `close on Windows: ${describe(err)}`)
471 }
472}
473
474function scriptFile($: Api, sys: Sys, name: string): string {
475 return [$.plugin.root, 'scripts', name].join(sys.sep)
476}
477
478function powershellFile(powershell: string, file: string): string[] {
479 return [powershell, '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', file]
480}
481
482function windowsLaunch(
483 launcher: string,
484 sys: Sys,
485 argv: readonly string[],
486 control: string,
487): { argv: string[]; cwd: string; env: Record<string, string> } | undefined {
488 const [exe, ...args] = argv
489 if (sys.powershell === undefined || exe === undefined) return undefined
490 if ([exe, ...args].some(a => /["\r\n]/.test(a) || a.endsWith('\\'))) return undefined
491 const line = args.map(a => (/\s/.test(a) ? `"${a}"` : a)).join(' ')
492 return {
493 argv: powershellFile(sys.powershell, launcher),
494 cwd: sys.home,
495 env: {
496 BADTIME_EXE: exe,
497 BADTIME_ARGS: line,
498 BADTIME_CONTROL: control,
499 BADTIME_RESUME_MS: String(cfg.resumeMs),
500 },
501 }
502}
503
504function stopStream(w: Win): void {
505 try {
506 void w.stream.return(undefined as unknown as ProcessSpawnResult).catch(() => undefined)
507 } catch {}
508}
509
510async function forceClose($: Api, w: Win): Promise<void> {
511 try {
512 const sys = await system($)
513 if (sys !== undefined) await killSlot($, sys, w.slot)
514 stopStream(w)
515 } catch (err) {
516 debug($, `force close: ${describe(err)}`)
517 }
518}
519
520function profileDir(sys: Sys, slot: number): string {
521 return [sys.home, '.bad-time', 'profiles', `slot-${slot}`].join(sys.sep)
522}
523
524function slotKey(slot: number): string {
525 return `slot-${slot}`
526}
527
528function isSlotRecord(v: unknown): v is SlotRecord {
529 if (typeof v !== 'object' || v === null) return false
530 const r = v as Record<string, unknown>
531 return typeof r.owner === 'string' && typeof r.beat === 'number'
532}
533
534async function claimSlot($: Api): Promise<number | undefined> {
535 for (let slot = 1; slot <= SLOT_COUNT; slot += 1) {
536 const key = slotKey(slot)
537 const held = await $.store.get(key)
538 const now = await $.clock.now()
539 if (isSlotRecord(held) && held.owner !== INSTANCE && now - held.beat < STALE_MS) continue
540 await $.store.set(key, { owner: INSTANCE, beat: now })
541 await pause($, 150)
542 const check = await $.store.get(key)
543 if (isSlotRecord(check) && check.owner === INSTANCE) return slot
544 }
545 return undefined
546}
547
548async function beat($: Api, slot: number): Promise<void> {
549 try {
550 const key = slotKey(slot)
551 const held = await $.store.get(key)
552 if (isSlotRecord(held) && held.owner !== INSTANCE) return
553 await $.store.set(key, { owner: INSTANCE, beat: await $.clock.now() })
554 } catch (err) {
555 debug($, `heartbeat: ${describe(err)}`)
556 }
557}
558
559async function releaseSlot($: Api, slot: number): Promise<void> {
560 try {
561 const key = slotKey(slot)
562 const held = await $.store.get(key)
563 if (isSlotRecord(held) && held.owner === INSTANCE) await $.store.delete(key)
564 } catch (err) {
565 debug($, `release: ${describe(err)}`)
566 }
567}
568
569async function reapStale($: Api): Promise<void> {
570 try {
571 const sys = await system($)
572 if (sys === undefined) return
573 for (let slot = 1; slot <= SLOT_COUNT; slot += 1) {
574 const key = slotKey(slot)
575 const held = await $.store.get(key)
576 if (!isSlotRecord(held) || held.owner === INSTANCE) continue
577 if ((await $.clock.now()) - held.beat < STALE_MS) continue
578 await killSlot($, sys, slot)
579 const again = await $.store.get(key)
580 if (isSlotRecord(again) && again.owner === held.owner && again.beat === held.beat) await $.store.delete(key)
581 }
582 } catch (err) {
583 debug($, `reap: ${describe(err)}`)
584 }
585}
586
587// Matches only a command line whose --user-data-dir is this slot's profile.
588function ownerPattern(slot: number): RegExp {
589 return new RegExp(`--user-data-dir="?[^"]*[\\\\/]\\.bad-time[\\\\/]profiles[\\\\/]slot-${slot}(?=["\\s]|$)`)
590}
591
592async function ownedPids($: Api, sys: Sys, slot: number): Promise<number[]> {
593 const pattern = ownerPattern(slot)
594 let listing = ''
595 if (sys.os === 'windows') {
596 if (sys.powershell === undefined) return []
597 const run = await $.process.run(powershellFile(sys.powershell, scriptFile($, sys, 'windows-processes.ps1')), {
598 cwd: sys.home,
599 timeoutMs: 20_000,
600 })
601 listing = run.stdout
602 } else {
603 if (sys.ps === undefined) return []
604 const run = await $.process.run([sys.ps, '-A', '-ww', '-o', 'pid=', '-o', 'command='], { cwd: sys.home })
605 listing = run.stdout
606 }
607 const pids: number[] = []
608 for (const line of listing.split(/\r?\n/)) {
609 const m = /^\s*(\d{1,10})\s+(.*)$/.exec(line)
610 if (m === null) continue
611 const pid = Number(m[1])
612 if (!Number.isSafeInteger(pid) || pid <= 1) continue
613 if (pattern.test(m[2] ?? '')) pids.push(pid)
614 }
615 return pids
616}
617
618async function mainPid($: Api, sys: Sys, slot: number): Promise<number | undefined> {
619 if (sys.ps === undefined) return undefined
620 const pattern = ownerPattern(slot)
621 const run = await $.process.run([sys.ps, '-A', '-ww', '-o', 'pid=', '-o', 'command='], { cwd: sys.home })
622 for (const line of run.stdout.split(/\r?\n/)) {
623 const m = /^\s*(\d{1,10})\s+(.*)$/.exec(line)
624 const command = m?.[2] ?? ''
625 if (m === null || !pattern.test(command) || command.includes('--type=')) continue
626 const pid = Number(m[1])
627 if (Number.isSafeInteger(pid) && pid > 1) return pid
628 }
629 return undefined
630}
631
632async function rendererPids($: Api, sys: Sys, slot: number): Promise<number[]> {
633 if (sys.ps === undefined) return []
634 const pattern = ownerPattern(slot)
635 const run = await $.process.run([sys.ps, '-A', '-ww', '-o', 'pid=', '-o', 'command='], { cwd: sys.home })
636 const pids: number[] = []
637 for (const line of run.stdout.split(/\r?\n/)) {
638 const m = /^\s*(\d{1,10})\s+(.*)$/.exec(line)
639 const command = m?.[2] ?? ''
640 if (m === null || !pattern.test(command) || !command.includes('--type=renderer')) continue
641 const pid = Number(m[1])
642 if (Number.isSafeInteger(pid) && pid > 1) pids.push(pid)
643 }
644 return pids
645}
646
647async function killSlot($: Api, sys: Sys, slot: number): Promise<void> {
648 try {
649 const pids = await ownedPids($, sys, slot)
650 if (pids.length === 0) return
651 debug($, `closing ${pids.length} leftover process(es) of slot ${slot}`)
652 if (sys.os === 'windows') {
653 if (sys.taskkill === undefined) return
654 for (const pid of pids) {
655 await $.process.run([sys.taskkill, '/PID', String(pid), '/T', '/F'], { cwd: sys.home }).catch(() => undefined)
656 }
657 return
658 }
659 if (sys.kill === undefined) return
660 await $.process.run([sys.kill, '-TERM', ...pids.map(String)], { cwd: sys.home }).catch(() => undefined)
661 await pause($, 2_000)
662 const left = await ownedPids($, sys, slot)
663 if (left.length > 0) {
664 await $.process.run([sys.kill, '-KILL', ...left.map(String)], { cwd: sys.home }).catch(() => undefined)
665 }
666 } catch (err) {
667 debug($, `close slot ${slot}: ${describe(err)}`)
668 }
669}
670
671function system($: Api): Promise<Sys | undefined> {
672 sysPromise ??= detectSystem($).catch(err => {
673 debug($, `system: ${describe(err)}`)
674 return undefined
675 })
676 return sysPromise
677}
678
679async function detectSystem($: Api): Promise<Sys | undefined> {
680 if ((await $.env.get('OS')) === 'Windows_NT') {
681 const home = await $.env.get('USERPROFILE')
682 if (home === undefined || !isWindowsAbsolute(home)) return undefined
683 const root = await $.env.get('SystemRoot')
684 const windowsDir = root !== undefined && isWindowsAbsolute(root) ? trimSep(root) : 'C:\\Windows'
685 return {
686 os: 'windows',
687 home: trimSep(home),
688 sep: '\\',
689 powershell: `${windowsDir}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`,
690 taskkill: `${windowsDir}\\System32\\taskkill.exe`,
691 }
692 }
693 const home = await $.env.get('HOME')
694 if (home === undefined || !home.startsWith('/')) return undefined
695 const os: Os = (await $.fs.exists('/System/Library/CoreServices/SystemVersion.plist')) ? 'mac' : 'linux'
696 return {
697 os,
698 home: trimSep(home),
699 sep: '/',
700 ps: await firstExisting($, ['/bin/ps', '/usr/bin/ps']),
701 kill: await firstExisting($, ['/bin/kill', '/usr/bin/kill']),
702 osascript: os === 'mac' ? await firstExisting($, ['/usr/bin/osascript']) : undefined,
703 player: os === 'linux' ? await firstExisting($, ['/usr/bin/paplay', '/usr/bin/aplay']) : undefined,
704 }
705}
706
707async function firstExisting($: Api, paths: readonly string[]): Promise<string | undefined> {
708 for (const path of paths) {
709 if (await $.fs.exists(path).catch(() => false)) return path
710 }
711 return undefined
712}
713
714async function findBrowser($: Api, sys: Sys, shouldWarn: boolean): Promise<string | undefined> {
715 if (cfg.browserPath !== '') {
716 const path = cfg.browserPath
717 const absolute = sys.os === 'windows' ? isWindowsAbsolute(path) : path.startsWith('/')
718 if (absolute && (await $.fs.exists(path).catch(() => false))) return path
719 if (shouldWarn) warnOnce($, 'browserPath', 'browserPath must be the full path to an installed browser; looking for one instead')
720 }
721 if (browserFound !== undefined) return browserFound
722 browserFound = await firstExisting($, await browserCandidates($, sys))
723 return browserFound
724}
725
726async function browserCandidates($: Api, sys: Sys): Promise<string[]> {
727 if (sys.os === 'mac') {
728 const apps = [
729 ['Google Chrome', 'Google Chrome'],
730 ['Microsoft Edge', 'Microsoft Edge'],
731 ['Chromium', 'Chromium'],
732 ['Brave Browser', 'Brave Browser'],
733 ]
734 return apps.flatMap(([app, binary]) => [
735 `/Applications/${app}.app/Contents/MacOS/${binary}`,
736 `${sys.home}/Applications/${app}.app/Contents/MacOS/${binary}`,
737 ])
738 }
739 if (sys.os === 'windows') {
740 const absolute = (v: string | undefined, fallback: string) =>
741 v !== undefined && isWindowsAbsolute(v) ? trimSep(v) : fallback
742 const programFiles = absolute(await $.env.get('ProgramFiles'), 'C:\\Program Files')
743 const programFiles86 = absolute(await $.env.get('ProgramFiles(x86)'), 'C:\\Program Files (x86)')
744 const localAppData = await $.env.get('LOCALAPPDATA')
745 const list = [
746 `${programFiles}\\Google\\Chrome\\Application\\chrome.exe`,
747 `${programFiles86}\\Google\\Chrome\\Application\\chrome.exe`,
748 `${programFiles86}\\Microsoft\\Edge\\Application\\msedge.exe`,
749 `${programFiles}\\Microsoft\\Edge\\Application\\msedge.exe`,
750 `${programFiles}\\BraveSoftware\\Brave-Browser\\Application\\brave.exe`,
751 ]
752 if (localAppData !== undefined && isWindowsAbsolute(localAppData)) {
753 list.splice(2, 0, `${trimSep(localAppData)}\\Google\\Chrome\\Application\\chrome.exe`)
754 }
755 return list
756 }
757 const names = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'microsoft-edge', 'brave-browser']
758 const fixed = ['/usr/bin', '/usr/local/bin', '/snap/bin']
759 // PATH entries are used only when absolute: a relative one could point
760 // into the repo Claude is working in.
761 const fromPath = ((await $.env.get('PATH')) ?? '').split(':').filter(dir => dir.startsWith('/'))
762 const dirs = [...new Set([...fixed, ...fromPath])]
763 return names.flatMap(name => dirs.map(dir => `${trimSep(dir)}/${name}`))
764}
765
766async function geometry($: Api, sys: Sys): Promise<{ w: number; h: number; x?: number; y?: number }> {
767 const fixed = parseSize(cfg.windowSize)
768 const screen = await screenSize($, sys)
769 if (fixed !== undefined) {
770 if (screen === undefined) return fixed
771 return { ...fixed, ...centered(screen, fixed) }
772 }
773 if (screen === undefined) return { w: 860, h: 700 }
774 let h = Math.round(screen.h * 0.62)
775 let w = Math.round(h * (4 / 3))
776 w = clamp(w, 640, Math.max(640, Math.round(screen.w * 0.8)))
777 h = clamp(h, 520, Math.max(520, Math.round(screen.h * 0.85)))
778 return { w, h, ...centered(screen, { w, h }) }
779}
780
781function centered(screen: { w: number; h: number }, size: { w: number; h: number }): { x: number; y: number } {
782 return { x: Math.max(0, Math.round((screen.w - size.w) / 2)), y: Math.max(0, Math.round((screen.h - size.h) / 2)) }
783}
784
785function screenSize($: Api, sys: Sys): Promise<{ w: number; h: number } | undefined> {
786 screenPromise ??= readScreen($, sys).catch(err => {
787 debug($, `screen size: ${describe(err)}`)
788 return undefined
789 })
790 return screenPromise
791}
792
793async function readScreen($: Api, sys: Sys): Promise<{ w: number; h: number } | undefined> {
794 let out = ''
795 if (sys.os === 'mac' && sys.osascript !== undefined) {
796 const run = await $.process.run([sys.osascript, '-l', 'JavaScript', scriptFile($, sys, 'mac-screen.js')], {
797 cwd: sys.home,
798 timeoutMs: 10_000,
799 })
800 out = run.stdout
801 } else if (sys.os === 'windows' && sys.powershell !== undefined) {
802 const run = await $.process.run(powershellFile(sys.powershell, scriptFile($, sys, 'windows-screen.ps1')), {
803 cwd: sys.home,
804 timeoutMs: 15_000,
805 })
806 out = run.stdout
807 } else {
808 return undefined
809 }
810 const m = /(\d{3,5})x(\d{3,5})/.exec(out)
811 if (m === null) return undefined
812 const w = Number(m[1])
813 const h = Number(m[2])
814 return w >= 640 && h >= 480 ? { w, h } : undefined
815}
816
817async function alertIfOpen($: Api): Promise<void> {
818 if (win !== undefined && !win.closing && !win.hidden) await needsYou($)
819}
820
821async function needsYou($: Api): Promise<void> {
822 notice($, 'Claude needs you.')
823 void signalGame($, 'ask')
824 await chime($, 'ask')
825}
826
827function notice($: Api, text: string): void {
828 try {
829 $.ui.toast(text, { timeoutMs: 8_000 })
830 } catch {}
831}
832
833async function signalGame($: Api, kind: 'ask' | 'done'): Promise<void> {
834 try {
835 const w = win
836 const sys = await system($)
837 if (w === undefined || w.control === undefined || sys?.os !== 'windows') return
838 w.seq += 1
839 await $.fs.write(w.control, `${kind} ${w.seq}`)
840 } catch (err) {
841 debug($, `banner: ${describe(err)}`)
842 }
843}
844
845function watchFocus($: Api, sys: Sys, w: Win): void {
846 if (sys.os !== 'mac' || sys.osascript === undefined || sys.kill === undefined) return
847 w.wasFront = true
848 w.focus = $.clock.every(500, () => {
849 if (w.focusBusy === true) return
850 w.focusBusy = true
851 void focusTick($, sys, w).finally(() => {
852 w.focusBusy = false
853 })
854 })
855}
856
857async function focusTick($: Api, sys: Sys, w: Win): Promise<void> {
858 try {
859 if (w.ended || w.closing) {
860 w.focus?.cancel()
861 return
862 }
863 const main = w.pid ?? (await mainPid($, sys, w.slot))
864 if (main === undefined) return
865 w.pid = main
866 const front = await frontPid($, sys)
867 if (front === undefined) return
868 const isFront = front === main
869 const now = await $.clock.now()
870 if (isFront && w.wasFront === false) w.resumeAt = now + cfg.resumeMs
871 if (!isFront) w.resumeAt = undefined
872 w.wasFront = isFront
873 const waiting = w.resumeAt !== undefined && now < w.resumeAt
874 if (!isFront || waiting) {
875 await freezePage($, sys, w)
876 } else {
877 w.resumeAt = undefined
878 await thawPage($, sys, w)
879 }
880 } catch (err) {
881 debug($, `focus: ${describe(err)}`)
882 }
883}
884
885async function freezePage($: Api, sys: Sys, w: Win): Promise<void> {
886 if (w.frozen !== undefined || sys.kill === undefined) return
887 const pids = await rendererPids($, sys, w.slot)
888 if (pids.length === 0) return
889 await $.process.run([sys.kill, '-STOP', ...pids.map(String)], { cwd: sys.home })
890 w.frozen = pids
891}
892
893async function thawPage($: Api, sys: Sys, w: Win): Promise<void> {
894 const pids = w.frozen
895 if (pids === undefined || sys.kill === undefined) return
896 w.frozen = undefined
897 await $.process.run([sys.kill, '-CONT', ...pids.map(String)], { cwd: sys.home })
898}
899
900async function endMacFreeze($: Api, w: Win): Promise<void> {
901 try {
902 const sys = await system($)
903 if (sys?.os === 'mac') await thawPage($, sys, w)
904 } catch {}
905}
906
907async function frontPid($: Api, sys: Sys): Promise<number | undefined> {
908 if (sys.osascript === undefined) return undefined
909 const run = await $.process.run([sys.osascript, '-l', 'JavaScript', scriptFile($, sys, 'mac-front.js')], {
910 cwd: sys.home,
911 timeoutMs: 5_000,
912 })
913 const pid = Number(run.stdout.trim())
914 return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined
915}
916
917async function chime($: Api, kind: 'ask' | 'done'): Promise<void> {
918 try {
919 if (!cfg.sounds) return
920 const now = await $.clock.now()
921 if (now - (lastChime[kind] ?? 0) < CHIME_GAP_MS) return
922 lastChime[kind] = now
923 const sys = await system($)
924 if (sys === undefined) return
925 if (sys.os === 'mac') {
926 await $.audio.play({ asset: `sounds/${kind}.wav` })
927 return
928 }
929 const file = [$.plugin.root, 'sounds', `${kind}.wav`].join(sys.sep)
930 if (sys.os === 'windows' && sys.powershell !== undefined) {
931 await $.process.run(powershellFile(sys.powershell, scriptFile($, sys, 'windows-chime.ps1')), {
932 cwd: sys.home,
933 timeoutMs: 10_000,
934 env: { BADTIME_SOUND: file },
935 })
936 } else if (sys.player !== undefined) {
937 await $.process.run([sys.player, file], { cwd: sys.home, timeoutMs: 10_000 })
938 }
939 } catch (err) {
940 debug($, `chime: ${describe(err)}`)
941 }
942}
943
944async function refill($: Api, text: string): Promise<void> {
945 try {
946 await $.prompt.fill({ text })
947 } catch (err) {
948 debug($, `refill: ${describe(err)}`)
949 }
950}
951
952function readConfig(o: PluginOptions, out: string[]): Config {
953 const str = (key: string, fallback: string): string => {
954 const v = o[key]
955 return typeof v === 'string' ? v.trim() : fallback
956 }
957 const num = (key: string, fallback: number, lo: number, hi: number): number => {
958 const v = o[key]
959 if (typeof v !== 'number' || !Number.isFinite(v)) return fallback
960 if (v < lo || v > hi) {
961 out.push(`${key} must be ${lo} to ${hi}`)
962 return fallback
963 }
964 return v
965 }
966 const bool = (key: string, fallback: boolean): boolean => {
967 const v = o[key]
968 return typeof v === 'boolean' ? v : fallback
969 }
970
971 let url = DEFAULT_URL
972 const rawUrl = str('url', DEFAULT_URL) || DEFAULT_URL
973 try {
974 const parsed = new URL(rawUrl)
975 if (parsed.protocol !== 'https:' || parsed.username !== '' || parsed.password !== '') throw new Error('not https')
976 url = parsed.href
977 } catch {
978 out.push('url must be an https:// address')
979 }
980
981 let windowSize = (str('windowSize', 'auto') || 'auto').toLowerCase()
982 if (windowSize !== 'auto' && parseSize(windowSize) === undefined) {
983 out.push('windowSize must be auto or WIDTHxHEIGHT, 320 to 3840 each')
984 windowSize = 'auto'
985 }
986
987 return {
988 url,
989 delayMs: Math.round(num('delaySeconds', 2, 0, 30) * 1000),
990 mode: readMode(str('onFinish', 'pause')),
991 countdownMs: Math.round(num('countdownSeconds', 3, 0, 10) * 1000),
992 resumeMs: Math.round(num('resumeDelaySeconds', 2.5, 0, 10) * 1000),
993 sounds: bool('sounds', true),
994 strayKeyGuard: bool('strayKeyGuard', true),
995 windowSize,
996 browserPath: str('browserPath', ''),
997 }
998}
999
1000function readMode(value: string): Mode {
1001 return value === 'close' ? 'close' : 'pause'
1002}
1003
1004function parseSize(text: string): { w: number; h: number } | undefined {
1005 const m = /^(\d{3,4})x(\d{3,4})$/.exec(text)
1006 if (m === null) return undefined
1007 const w = Number(m[1])
1008 const h = Number(m[2])
1009 return w >= 320 && w <= 3840 && h >= 320 && h <= 3840 ? { w, h } : undefined
1010}
1011
1012function pause($: Api, ms: number): Promise<void> {
1013 return new Promise(resolve => {
1014 $.clock.after(ms, () => resolve())
1015 })
1016}
1017
1018function warnOnce($: Api, key: string, text: string): void {
1019 if (warned.has(key)) return
1020 warned.add(key)
1021 try {
1022 $.ui.toast(text, { timeoutMs: 8_000 })
1023 } catch {}
1024 debug($, text)
1025}
1026
1027function debug($: Api, text: string): void {
1028 try {
1029 $.ui.log(`bad-time: ${text}`, { to: 'debug' })
1030 } catch {}
1031}
1032
1033function describe(err: unknown): string {
1034 return err instanceof Error ? err.message : String(err)
1035}
1036
1037function seconds(ms: number): string {
1038 const s = ms / 1000
1039 return `${s} ${s === 1 ? 'second' : 'seconds'}`
1040}
1041
1042function clamp(v: number, lo: number, hi: number): number {
1043 return Math.min(hi, Math.max(lo, v))
1044}
1045
1046function isWindowsAbsolute(path: string): boolean {
1047 return /^[A-Za-z]:\\[^"<>|?*]*$/.test(path)
1048}
1049
1050function trimSep(path: string): string {
1051 return path.length > 1 ? path.replace(/[\\/]+$/, '') : path
1052}
1053
1054
1055function makeId(): string {
1056 try {
1057 return crypto.randomUUID()
1058 } catch {
1059 return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
1060 }
1061}
1062