SLOPSHOPPER

bad-time

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…

newguardcommandtoastpromptprocess
v1.0.1MITupdated 2026-10-06FatihBastan/bad-time
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bad-time
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /badtime ⎿ bad-time: bad-time is on. The game opens once Claude has been working for 2 seconds. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

bad-time

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.

What it does

  • Once Claude has been working for 2 seconds, a centered game window opens: about 60% of your screen height, in the game's 4:3 shape, and resizable.
  • When Claude is done, you hear the done chime, Claude shows "Claude is done.", and on Windows a small white CLAUDE IS DONE banner appears across the top of the game. The game stays where it is: minimize it yourself whenever you like. Your next prompt brings the same fight back to the front. Quitting the session closes it.
  • When Claude needs you (a permission prompt or a question), you hear a different, more urgent ask chime, Claude shows a notice, and on Windows a small white CLAUDE NEEDS YOU banner appears across the top of the game and Claude's taskbar button flashes. Finish the attack (the FIGHT/ACT/ITEM/MERCY menu waits for you), then switch to Claude and answer. Banners go away once you leave the game. The mod never moves your keyboard focus and never answers or changes anything for Claude.
  • The fight runs only while the game window is in front: click away and it freezes; click back and it carries on 2.5 seconds later, with a 3-2-1 countdown over the game on Windows.
  • Close the window yourself any time; your next prompt starts a new fight.
  • Up to 4 Claude sessions can each have their own window.

Install

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

Use

CommandWhat it does
/badtime or /badtime onTurns it on (remembered across sessions)
/badtime offTurns it off and closes the window
/badtime nowOpens the game, or brings a paused one back, even when Claude is idle
/badtime closeCloses the window; it opens again the next time Claude works
/badtime statusShows whether it is on, the window, the browser and the URL

Settings

Change these in /config or with /plugin configure bad-time@bad-time.

SettingDefaultNotes
onFinishpausepause 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
delaySeconds20 to 30; shorter turns never open the window
countdownSeconds30 to 10; with close, seconds between the chime and the window closing
resumeDelaySeconds2.50 to 10; how long the fight stays frozen after you click back into the game
soundsonThe ask and done chimes
strayKeyGuardonHolds a prompt sent within 1.5 s of the mod closing the window and puts it back in the box
windowSizeautoOr WIDTHxHEIGHT, such as 900x700, 320 to 3840 each
browserPathemptyFull path to a Chromium-based browser; empty means find one
urlBad Time SimulatorMust 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.

Safety

  • Hands off Claude. Every hook passes Claude's events on untouched, and the mod never answers, changes or decides a permission prompt or a question. The only thing it ever holds back is your own prompt, typed within 1.5 s of the mod closing the game window (turn it off with strayKeyGuard). If the mod fails, Claude carries on as if it were not installed.
  • Its own browser profile. The game runs in ~/.bad-time/profiles/slot-N, with no logins, cookies or extensions, separate from your normal browser.
  • Closes only its own windows. A leftover game window is found by that profile folder in its command line, never by a saved process id.
  • Readable helpers only. Every helper is a file in scripts/, run by its absolute path, without a shell and without inline or encoded code, from your home folder.

What it runs and sends

Programs it starts

ProgramWhenWhy
Chrome, Edge, Chromium or Brave, found at its usual install path (or browserPath)Claude has worked for 2 sShows 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.ps1With the gameStarts 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 launcherWith the gameCloses the game window if the launcher is gone (Claude exited)
Windows: scripts/windows-processes.ps1, then taskkill.exeAt session start and when closingFinds game windows left by a crashed session, by their bad-time profile folder, and closes them
Windows: scripts/windows-screen.ps1, scripts/windows-chime.ps1Opening the game; a chimeReads the screen size to center the window; plays sounds/ask.wav or sounds/done.wav
macOS: /bin/ps, /bin/killWhile the game is openFinds 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.jsTwice a second while the game is open; opening it; your next promptWhich app is in front; the screen size; bring the game back to the front
Linux: ps, kill, paplay or aplayAs aboveFind and close the game; play a chime

Network

  • The mod itself makes no network requests.
  • The game browser loads https://jcw87.github.io/c2-sans-fight/ (Bad Time Simulator on GitHub Pages), or the address in the 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.

What it reads and writes

  • Environment variables: HOME, PATH (absolute entries only), OS, USERPROFILE, SystemRoot, ProgramFiles, ProgramFiles(x86), LOCALAPPDATA, only to find your home folder, the browser and system tools.
  • Files: checks that the browser and helper programs exist; reads its own 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).
  • Claude Code's plugin store: whether bad-time is on, and which of the 4 window slots each session holds.
  • It never reads credentials, your code, your prompts or Claude's replies.

Hooks

HookWhat it doesChanges the event?
session.startRegisters /badtime, closes leftover game windows of crashed sessionsNo, returns next(e)
turn.startOpens the game, or brings it back, once Claude has worked 2 sNo
turn.completeDone chime, "Claude is done." notice, banner; in close mode closes the window after the countdownNo
classic.PermissionRequestAsk chime, "Claude needs you." notice, banner. Never decides the permissionNo, always return next(e)
tool.call (AskUserQuestion)The same alert. Never answers the questionNo, always return next(e)
prompt.submitStray-key guard: a prompt typed by you within 1.5 s of the mod closing the game window goes back into the prompt box unsentOnly that case; off with strayKeyGuard
session.endCloses the gameNo
command.run (badtime)Answers its own /badtime commandOnly its own command

Privacy

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.

Develop

claude plugin validate --strict .
claude plugin test .

Support and bug reports: GitHub Issues.

Credits

  • Toby Fox created UNDERTALE: Sans, his fight, and its music, including "MEGALOVANIA", are his. UNDERTALE is a trademark of Toby Fox.
  • Jcw87 made Bad Time Simulator (source), the free browser recreation of the fight that this mod opens.
  • This mod only opens that page. If you enjoy the fight, play UNDERTALE.

License

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.

Source 1 files
hooks/register.ts 1062 lines
1// 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