Hands models to the right slicer: full-spectrum (Snapmaker U1) files Claude tries to open in Bambu Studio go to Snapmaker Orca instead, and previous slicer…

Claude Code mods (function-hook plugins) I find helpful. Each folder is one plugin.
Requires a Claude Code build with function-hook plugins (2.1.289 or newer). machine-guard reads macOS tools (sysctl, memory_pressure, ioreg).
git clone https://github.com/joeldg/claude-mods ~/Projects/claude-mods
A pane of your project's running dev servers, so you don't have to ask Claude to restart them.
/servers opens the Servers pane:package.json dev/start/serve/preview scripts (run with your lockfile's package manager), .claude/launch.json and Procfile~/.claude/dev-servers/<project>/.Port 4000 is held by node (pid 123, up 2h, in /Users/me/other).servers: :4000 :5173.lsof and ps every 15s.Puts files you just downloaded into your prompt with one click.
~/Downloads (top level). When a new file arrives (PDF, Markdown, images, 3MF/STL/OBJ, zip, video…), a band appears above the prompt: New in Downloads: paper.pdf, model-b.3mf · 2m ago [Attach] [Dismiss].@"/Users/you/Downloads/paper.pdf" mentions in your prompt. Dismiss hides those files./downloads lists the 10 newest files, numbered. /downloads attach 1 3 (or 2-4) adds those, and /downloads clear dismisses everything new.Settings: folder (~/Downloads), extensions, pollSeconds (5), maxAgeMinutes (120).
Sets effort per message, so you don't have to switch it by hand.
avoidModel (a regex such as fable) to send those requests to fallbackModel instead, subagents included./route shows the last decision and the session's counts. /route off and /route on toggle it; /route deep and /route routine force the next turn.effort: low (routine).Settings: routineEffort (low), deepEffort (max), routinePattern, deepPattern, avoidModel, fallbackModel (opus), stickyTurns (2), freeSwitchTokens (30000), cacheTtlMinutes (60).
A Jobs pane for long-running work: training runs, downloads, extractions.
nohup … > log & launches by itself./watch <log> [label] adds any other log file./ and /Volumes/* (the NAS).jobs: 2 running · 1 stalled./jobs opens the pane, /unwatch <label|done|all> removes jobs.tail, checks processes with ps, and runs df.Settings (in /config): stall minutes (10), refresh seconds (10), how long finished jobs stay (120 min), auto-open (on), which disks to show.
Memory, swap and GPU on the status line. It refuses heavy local jobs when the Mac can't take them.
RAM tight 12% free · swap 7.9/8G · top python 31G · GPU 87%./busy.When memory is only tight, the job runs and Claude gets a note to start one heavy job at a time.
/busy 3h training a vision model reserves the Mac in every Claude session. /busy off lifts it. The reservation lives in ~/.claude/machine-guard.json, so a training script can write it too: ``bash echo '{"reason":"overnight training","until":'$(( ($(date +%s) + 8*3600) * 1000 ))'}' > ~/.claude/machine-guard.json ``/guard shows what it sees. /guard pause 15m lets heavy jobs through in this session; /guard on resumes the guard.modal run, ssh), tests (pytest) and installs are never treated as heavy.overnight_|nightly_run\.sh.Watches how the other mods behave in real use, without changing them. It is listed first in CLAUDE_CODE_PLUGIN_DIRS, so the other mods' hooks run beneath it.
next.trace), with the mod's name, the event and how long it ran. Slow hooks (over 1.5 s) are recorded too. The first failure of each mod in a session raises a toast./second-opinion, /recall ask) and file writes (folders only, never contents)./mods: a pane with one row per mod: ✓ active, ⚠ failing, ✗ not seen this session, · seen but idle. Each row shows today's counts and last activity, with Details for its recent events. It also says which mods it can't see, if any of them run above it./mods report [24h|7d|30d]: a per-mod report across all sessions, also written to ~/.claude/mods/monitor/report-latest.md for a scheduled review or Claude to read./mods failures [7d]: failures and failed subprocesses only.~/.claude/mods/monitor/<date>/<session>.jsonl, flushed every minute and at session end, with secrets masked and old days removed after 30 days.$.ui.log with wording like "failed" or "could not"): shown in Details and in /mods failures. Three in an hour mark the mod ⚠ and raise one toast. That is how effort-router's per-request hook, which runs inside the response stream where no monitor should sit, reports a failure. It also always sends the request on unchanged./secrets records there) are watched for failures and slow runs, but not counted per run.Settings: alerts (on), slowMs (1500), watchRender (on), watchCommands (on; off stops "mod-monitor" appearing beside other mods' command output), watchAppend (on), retentionDays (30), flushSeconds (60).
Keeps an eye on Modal so idle GPU containers don't burn credits.
Modal: 1 running (2 containers). Deployed apps with no containers cost nothing, so they stay off it.alertMinutes (30), repeated at most every 30 minutes./modal opens a pane of apps with state, containers and uptime. Stop asks for Confirm, then runs modal app stop. Nothing is stopped any other way.budgetToday where the Modal CLI supports billing report (1.3.3+, Team/Enterprise workspaces). Otherwise /modal says why spend isn't shown.modal or python3 -m modal. It checks PATH first rather than running a command that can only fail, and stays silent when Modal isn't set up.Does the "merged #219, clean up branches and start #214" round trip for you, and surfaces CI failures with their logs.
gh pr create Claude runs. It polls gh pr view every 60s.PRs: #219 ✓ · #220 CI… · #221 ✗. Toasts when CI fails (with the failing check names) or passes.git fetch --prune, switch to the default branch (only from the PR's own branch) and git pull --ff-only.--force, never other branches.gh pr checks and the tail of the failed log attached, so you don't paste it./prs lists watched PRs. /prs watch <n|url> and /prs forget <n|all> add and remove them.gh and git, at about one GitHub API call per open PR per minute.Settings:
pollSeconds (60)attachCiLogs (on)logLines (120)deleteRemoteBranch (off): deletes the branch on GitHub too, only while it still points at the merged commit. GitHub's own "Automatically delete head branches" setting does the same job.It never closes issues; put "Closes #N" in PR bodies for that.
Search everything you've done with coding agents, from Claude or from /recall. It replaces the broken agent-memory plugin.
/remember notes. Routine (scheduled) runs are left out unless you add routines:include to a query.grep and cat are kept but ranked low.search, expand, recap and list, which run without permission prompts. It checks them when you say "like last time" or "what did we decide", and before asking you something you already settled./recall <query> opens a pane of hits grouped by session. Open shows the conversation around a hit, Attach sends it with your next message, and Copy resume command copies claude --resume <id>./recall last [n] recaps your last session in this repo: last asks, last answer, PRs, commits, open tasks and decisions. Send to Claude attaches it./recall timeline [7d|30d|90d] [all]/recall decisions|commands|files|prs|commits|issues|urls|tasks|notes [query]/recall ask <question> answers from your history with Haiku 4.5, citing sessions. It costs a little usage and sends the matching excerpts to the model./recall stats, /recall reindex, /recall forget session <id>|project <name>|before <date> (asks you to confirm), /recall help./remember <fact>, /remember list, /remember forget <ref>.Last session here (2d ago): "…" · PR #99 · 3 open tasks [Recap].#214, ABC-12, a file name or a quoted phrase seen in past sessions, a band offers what happened then. Nothing is sent unless you click.OR gives alternatives, "quotes" an exact phrase, and -word excludes. Filters: project:name, kind:decision, since:7d, until:2026-09-30, source:codex, routines:include.~/.claude/recall/index.db, readable only by you, and never goes in a repo.~/.zshrc, ~/.zprofile, ~/.bashrc and ~/.bash_profile, and any literal strings you list in ~/.claude/recall/redact.txt (one per line). Editing that list re-masks the existing index on the next update./recall ask. The first index takes about 2 minutes in the background, with progress on the status line. After that it updates incrementally (about 1s) at session start and every 10 minutes./usr/bin/python3 (Command Line Tools), whose SQLite has FTS5. Nothing else to install.Settings: dbPath, python, sources, includeSubagents (on), includeRoutines (off), updateMinutes (10), relatedBand (on), lastSessionBand (on), maxResults (8), askModel (claude-haiku-4-5-20251001).
Catches Claude up on the repo when a session starts, so you don't have to ask "check the recent commits/PRs and issues".
owner, todo, P0 or blockedmain ↑1 · 3 changed · PRs #123 ✗ #124 ✓ · 2 owner issues · 2 stale branches · last commit 2h ago. Hide dismisses it./brief re-gathers now and prints the full summary.git and gh. The band refreshes after a turn at most every 2 minutes.Settings: focus labels, refresh minutes, and whether to brief Claude.
Keeps scheduled routines (daily digests, newsletters) from silently stalling while you're away.
AskUserQuestion, you get a Mac notification and a toast, and the status line shows routine: daily-report · waiting on you 3m.Routine daily-report finished after 23m · waited on you 2 times.notifyCommand runs a command on the same events, e.g. curl -s -d {message} ntfy.sh/your-topic. {title} and {message} are filled in as single arguments, never through a shell.allowWebReads (off by default): lets routines use WebFetch and WebSearch without asking. It only replaces a prompt; your deny rules still apply, and nothing else is ever auto-allowed./routine shows the routine's name, how long it has run, its waits, and the settings.A Fable review in the background, without switching your session's model. Each run is one Fable call against your usage.
/second-opinion: reviews recent work. On a feature branch that's the branch against the default branch; otherwise the last 12 commits, plus the diff and git status, capped at 60k characters./second-opinion commits 5/second-opinion diff (uncommitted changes)/second-opinion file docs/ADR-007.md/second-opinion <question>: adds a question for Fable to answer first.second opinion: reviewing…. When the review is ready you get a toast, and a pane opens with it, ranked: wrong assumptions, bugs and risks, what's missing, what to do next.~/.claude/second-opinions/<project>/. /second-opinion list lists them, and /second-opinion show [n] reopens one.Settings: model (claude-fable-5-1), effort (high), maxContextChars (60000).
Keeps your "always / never / don't / from now on" instructions alive across compaction.
Keep as a standing order? [Project] [This session] [No]. Nothing is saved without a click.~/.claude/standing-orders/<repo>.json and apply to every session in that repo. Session orders and your active /goal last for the session./clear, so the prompt cache isn't disturbed. A newly saved order also rides along once with your next message./orders lists them. /orders add [project|session] <text>, /orders forget <n>, /orders clear session|project, and /orders export (a Markdown block for CLAUDE.md).Stops keys and passwords from going into a prompt, and so into your transcripts, and turns them into env vars instead.
$NAME references, placeholders, plain URLs, paths, git SHAs and ordinary prose about passwords.…vxrm) with a suggested name such as OPENDATALAB_SECRET_ACCESS_KEY, which you can edit:export NAME='…' to ~/.zshrc (reusing an existing identical export) and replaces the secret in your prompt with $NAME./secrets test <text> output is masked too./secrets test <text> shows what would be caught. /secrets off and /secrets on toggle it for the session.Settings: enabled (on), extraPatterns (a regex), zshrcPath (~/.zshrc).
Makes Claude's open commands hand 3D files to the right slicer.
full.?spectrum|snapmaker-only|-fs\.3mf$|-u1[-.], orAn open -a BambuStudio … for one becomes open -b com.snapmaker.snapmaker-orca …, with the rest of the command untouched. You get a toast, and Claude gets a note so it doesn't try again.
/slice <file> [bambu|snapmaker|orca] opens a file yourself, with the same rules.open -a <app>, open -a /Applications/X.app and open -b <bundle id>, including variables set earlier in the command (S=… && open -a BambuStudio "$S/x.3mf") and files copied in the same command.Settings:
closePrevious (on): turn it off if you keep your own slicer window open, since the quit request reaches your windows too.fullSpectrumPattern (the regex above)checkContents (on)The quit request goes out when Claude issues the command, before any permission prompt for it.
--plugin-dir once per mod, e.g. claude --plugin-dir ~/Projects/claude-mods/job-watch --plugin-dir ~/Projects/claude-mods/pr-autopilot~/.claude/settings.json. Put mod-monitor first so it sees the others; CLAUDE_CODE_PLUGIN_DIR_WATCH makes desktop sessions pick up edits and show mod failures: ``json { "env": { "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1", "CLAUDE_CODE_PLUGIN_DIRS": "~/Projects/claude-mods/mod-monitor:~/Projects/claude-mods/job-watch:~/Projects/claude-mods/machine-guard:~/Projects/claude-mods/repo-brief:~/Projects/claude-mods/slicer-handoff:~/Projects/claude-mods/pr-autopilot:~/Projects/claude-mods/routine-watch:~/Projects/claude-mods/modal-meter:~/Projects/claude-mods/second-opinion:~/Projects/claude-mods/downloads-drop:~/Projects/claude-mods/dev-servers:~/Projects/claude-mods/standing-orders:~/Projects/claude-mods/effort-router:~/Projects/claude-mods/secret-guard:~/Projects/claude-mods/recall" } } ``Run with Claude Code 2.1.289 or newer; older CLIs ignore per-test settings, so a few tests fall back to defaults.
claude plugin validate job-watch && claude plugin test job-watch
claude plugin validate machine-guard && claude plugin test machine-guard
claude plugin validate repo-brief && claude plugin test repo-brief
claude plugin validate slicer-handoff && claude plugin test slicer-handoff
claude plugin validate pr-autopilot && claude plugin test pr-autopilot
claude plugin validate routine-watch && claude plugin test routine-watch
claude plugin validate modal-meter && claude plugin test modal-meter
claude plugin validate second-opinion && claude plugin test second-opinion
claude plugin validate downloads-drop && claude plugin test downloads-drop
claude plugin validate dev-servers && claude plugin test dev-servers
claude plugin validate standing-orders && claude plugin test standing-orders
claude plugin validate effort-router && claude plugin test effort-router
claude plugin validate secret-guard && claude plugin test secret-guard
claude plugin validate recall && claude plugin test recall
claude plugin validate mod-monitor && claude plugin test mod-monitor
(cd recall/engine && /usr/bin/python3 -m unittest)hooks/register.ts 219 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4 closedToast,
5 contentPaths,
6 countPids,
7 fileName,
8 findSlicerOpens,
9 FULL_SPECTRUM_MARKER,
10 fullSpectrumPattern,
11 mentionsOpen,
12 nameSaysFullSpectrum,
13 parseSliceArgs,
14 quitScript,
15 rerouteNote,
16 rerouteToast,
17 rewriteOpens,
18 SLICERS,
19 stillOpenNote,
20} from './handoff'
21import type { OpenFile, Place, Slicer, SlicerOpen } from './handoff'
22
23type Engine = EngineInterface
24
25type Config = { closePrevious: boolean; pattern: RegExp; checkContents: boolean }
26
27let config: Config = { closePrevious: true, pattern: fullSpectrumPattern(undefined), checkContents: true }
28
29/** Quit requests sent to one slicer before giving up (Snapmaker Orca can run several instances). */
30const MAX_QUITS = 5
31/** The wait after each quit request before counting the instances again. */
32const QUIT_WAIT_MS = 1_000
33/** A quit that waits on a save dialog holds `osascript`; past this it counts as refused. */
34const QUIT_TIMEOUT_MS = 5_000
35const PROBE_TIMEOUT_MS = 5_000
36const OPEN_TIMEOUT_MS = 15_000
37
38const USAGE = 'Usage: /slice <file> [bambu|snapmaker|orca]. Full-spectrum files always open in Snapmaker Orca; others in Bambu Studio unless you name a slicer.'
39
40async function placeOf($: Engine): Promise<Place> {
41 const [cwd, home] = await Promise.all([
42 $.session.cwd().catch(() => null),
43 $.env.get('HOME').catch(() => undefined),
44 ])
45 return { cwd: cwd || null, home: home || null }
46}
47
48/** Whether a resolved 3MF's project settings name a Full Spectrum filament profile. */
49async function contentsSayFullSpectrum($: Engine, file: OpenFile): Promise<boolean> {
50 for (const path of contentPaths(file)) {
51 const out = await $.process
52 .run(['unzip', '-p', path, 'Metadata/project_settings.config'], { timeoutMs: PROBE_TIMEOUT_MS })
53 .catch(() => null)
54 if (out && out.exitCode === 0) {
55 return out.stdout.includes(FULL_SPECTRUM_MARKER)
56 }
57 }
58 return false
59}
60
61async function isFullSpectrum($: Engine, file: OpenFile): Promise<boolean> {
62 return nameSaysFullSpectrum(file, config.pattern) || (config.checkContents && (await contentsSayFullSpectrum($, file)))
63}
64
65async function countRunning($: Engine, slicer: Slicer): Promise<number> {
66 const out = await $.process.run(['pgrep', '-x', SLICERS[slicer].process], { timeoutMs: PROBE_TIMEOUT_MS }).catch(() => null)
67 return out && out.exitCode === 0 ? countPids(out.stdout) : 0
68}
69
70/** One normal quit request (never a kill); false when it was refused or is still waiting on a dialog. */
71async function askToQuit($: Engine, slicer: Slicer): Promise<boolean> {
72 const out = await $.process.run(['osascript', '-e', quitScript(slicer)], { timeoutMs: QUIT_TIMEOUT_MS }).catch(() => null)
73 return out !== null && out.exitCode === 0
74}
75
76/**
77 * Gently quits a slicer's running instances: up to MAX_QUITS normal quit requests while any
78 * remain, a second apart, stopping early at one that refuses (its save dialog was cancelled or is
79 * still up). Answers how many went away and how many are left.
80 */
81async function closeRunning($: Engine, slicer: Slicer): Promise<{ closed: number; left: number }> {
82 const before = await countRunning($, slicer)
83 let running = before
84 for (let attempt = 0; attempt < MAX_QUITS && running > 0; attempt++) {
85 const isAccepted = await askToQuit($, slicer)
86 await $.clock.sleep(QUIT_WAIT_MS)
87 running = await countRunning($, slicer)
88 if (!isAccepted && running > 0) {
89 break
90 }
91 }
92 return { closed: Math.max(0, before - running), left: running }
93}
94
95/** Closes a slicer's previous instances before a file opens in it; the note for the model when some stay open. */
96async function closeBeforeOpening($: Engine, slicer: Slicer, name: string): Promise<string | null> {
97 const { closed, left } = await closeRunning($, slicer)
98 if (closed > 0) {
99 $.ui.toast(closedToast(slicer, closed, name))
100 }
101 return left > 0 ? stillOpenNote(slicer, left) : null
102}
103
104/** The full-spectrum files among an open's, by name first and then by contents. */
105async function fullSpectrumFiles($: Engine, open: SlicerOpen): Promise<OpenFile[]> {
106 const found: OpenFile[] = []
107 for (const file of open.files) {
108 if (await isFullSpectrum($, file)) {
109 found.push(file)
110 }
111 }
112 return found
113}
114
115async function slice($: Engine, args: string): Promise<string> {
116 const { file, wanted } = parseSliceArgs(args, await placeOf($))
117 if (!file) {
118 return USAGE
119 }
120 if (!file.path) {
121 return `slicer-handoff: could not resolve ${file.written} to a file path. ${USAGE}`
122 }
123 if (!(await $.fs.exists(file.path).catch(() => false))) {
124 return `slicer-handoff: no such file: ${file.path}`
125 }
126 const name = fileName(file)
127 const isFs = await isFullSpectrum($, file)
128 const slicer: Slicer = isFs ? 'snapmaker' : (wanted ?? 'bambu')
129 const { label, bundle } = SLICERS[slicer]
130 const lines: string[] = []
131 if (isFs) {
132 lines.push(
133 wanted && wanted !== 'snapmaker'
134 ? `${name} is a full-spectrum file, which ${SLICERS[wanted].label} cannot open: using Snapmaker Orca.`
135 : `${name} is a full-spectrum file: using Snapmaker Orca.`,
136 )
137 }
138 if (config.closePrevious) {
139 const { closed, left } = await closeRunning($, slicer)
140 if (closed > 0) {
141 lines.push(`Closed ${closed} previous ${label} window(s).`)
142 }
143 if (left > 0) {
144 lines.push(`${left} ${label} instance(s) still open, probably asking to save; left as they are.`)
145 }
146 }
147 const out = await $.process.run(['open', '-b', bundle, file.path], { timeoutMs: OPEN_TIMEOUT_MS }).catch(() => null)
148 lines.push(
149 out && out.exitCode === 0
150 ? `Opened ${file.path} in ${label}.`
151 : `Could not open ${file.path} in ${label}: ${out?.stderr.trim() || 'open failed'}`,
152 )
153 return lines.join('\n')
154}
155
156export const register: Register = (on, options) => {
157 config = {
158 closePrevious: options.closePrevious !== false,
159 pattern: fullSpectrumPattern(options.fullSpectrumPattern),
160 checkContents: options.checkContents !== false,
161 }
162
163 on('session.start', async ($, e, next) => {
164 await $.command.register({
165 name: 'slice',
166 description: 'Open a model in the right slicer: Snapmaker Orca for full-spectrum files, else Bambu Studio',
167 argumentHint: '<file> [bambu|snapmaker|orca]',
168 })
169 return next(e)
170 })
171
172 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
173 if (!mentionsOpen(e.command)) {
174 return next(e)
175 }
176 const opens = findSlicerOpens(e.command, await placeOf($))
177 if (opens.length === 0) {
178 return next(e)
179 }
180 const notes: string[] = []
181 const rerouted: SlicerOpen[] = []
182 for (const open of opens) {
183 if (open.slicer === 'snapmaker') {
184 continue
185 }
186 const found = await fullSpectrumFiles($, open)
187 if (found.length > 0) {
188 const names = found.map(fileName)
189 rerouted.push(open)
190 $.ui.toast(rerouteToast(open.slicer, names))
191 notes.push(rerouteNote(open, names))
192 }
193 }
194 if (config.closePrevious) {
195 const targets = new Map<Slicer, string>()
196 for (const open of opens) {
197 const target = rerouted.includes(open) ? 'snapmaker' : open.slicer
198 if (!targets.has(target) && open.files[0]) {
199 targets.set(target, fileName(open.files[0]))
200 }
201 }
202 for (const [slicer, name] of targets) {
203 const note = await closeBeforeOpening($, slicer, name)
204 if (note) {
205 notes.push(note)
206 }
207 }
208 }
209 const command = rewriteOpens(e.command, rerouted, 'snapmaker')
210 const ran = await next(command === e.command ? e : { ...e, command })
211 if (ran.deny !== undefined || notes.length === 0) {
212 return ran
213 }
214 return { ...ran, context: [...(ran.context ?? []), ...notes] }
215 })
216
217 on('command.run', { command: 'slice' }, async ($, e) => ({ text: await slice($, e.args) }))
218}
219hooks/handoff.ts 717 lines1/** The slicers on this Mac that files are handed to. */
2export type Slicer = 'bambu' | 'snapmaker' | 'orca'
3
4export type SlicerInfo = {
5 /** What `open -b` and `quit app id` take. */
6 bundle: string
7 /** The executable's name, as `pgrep -x` matches it. */
8 process: string
9 /** The app's name as the person knows it. */
10 label: string
11}
12
13export const SLICERS: Readonly<Record<Slicer, SlicerInfo>> = {
14 bambu: { bundle: 'com.bambulab.bambu-studio', process: 'BambuStudio', label: 'Bambu Studio' },
15 snapmaker: { bundle: 'com.snapmaker.snapmaker-orca', process: 'Snapmaker_Orca', label: 'Snapmaker Orca' },
16 orca: { bundle: 'com.orcaslicer.OrcaSlicer', process: 'OrcaSlicer', label: 'OrcaSlicer' },
17}
18
19export const DEFAULT_FULL_SPECTRUM = 'full.?spectrum|snapmaker-only|-fs\\.3mf$|-u1[-.]'
20
21/** What a Snapmaker U1 full-spectrum project's settings name: its `Snapmaker PLA Full Spectrum @U1` profiles. */
22export const FULL_SPECTRUM_MARKER = 'Full Spectrum'
23
24/** The full-spectrum path pattern from the option, case-insensitive; the default when it is empty or not a valid regex. */
25export const fullSpectrumPattern = (source: unknown): RegExp => {
26 const text = typeof source === 'string' && source.trim() !== '' ? source : DEFAULT_FULL_SPECTRUM
27 try {
28 return new RegExp(text, 'i')
29 } catch {
30 return new RegExp(DEFAULT_FULL_SPECTRUM, 'i')
31 }
32}
33
34// ---------------------------------------------------------------------------------------------
35// Splitting a command into simple commands and words
36
37/** One shell word as written: its source text and where it sits in the command. */
38export type Word = { raw: string; start: number; end: number }
39
40function skipSingle(s: string, i: number): number {
41 const close = s.indexOf("'", i)
42 return close < 0 ? s.length : close + 1
43}
44
45function skipBacktick(s: string, i: number): number {
46 while (i < s.length) {
47 if (s[i] === '\\') {
48 i += 2
49 } else if (s[i] === '`') {
50 return i + 1
51 } else {
52 i++
53 }
54 }
55 return s.length
56}
57
58/** Index just past the `"` closing a double-quoted string whose body starts at `i`. */
59function skipDouble(s: string, i: number): number {
60 while (i < s.length) {
61 const ch = s[i]
62 if (ch === '\\') {
63 i += 2
64 } else if (ch === '"') {
65 return i + 1
66 } else if (ch === '$' && s[i + 1] === '(') {
67 i = skipParens(s, i + 2)
68 } else if (ch === '`') {
69 i = skipBacktick(s, i + 1)
70 } else {
71 i++
72 }
73 }
74 return s.length
75}
76
77/** Index just past the `)` closing a `$(` whose body starts at `i`. */
78function skipParens(s: string, i: number): number {
79 let depth = 1
80 while (i < s.length) {
81 const ch = s[i]
82 if (ch === '\\') {
83 i += 2
84 } else if (ch === "'") {
85 i = skipSingle(s, i + 1)
86 } else if (ch === '"') {
87 i = skipDouble(s, i + 1)
88 } else if (ch === '`') {
89 i = skipBacktick(s, i + 1)
90 } else if (ch === '(') {
91 depth++
92 i++
93 } else if (ch === ')') {
94 depth--
95 i++
96 if (depth === 0) {
97 return i
98 }
99 } else {
100 i++
101 }
102 }
103 return s.length
104}
105
106/** Index just past a heredoc's body, which starts at `i`, and its terminator line. */
107function skipHeredoc(s: string, i: number, doc: { tag: string; strip: boolean }): number {
108 while (i < s.length) {
109 const nl = s.indexOf('\n', i)
110 const line = s.slice(i, nl < 0 ? s.length : nl).replace(/\r$/, '')
111 i = nl < 0 ? s.length : nl + 1
112 if ((doc.strip ? line.replace(/^\t+/, '') : line) === doc.tag) {
113 break
114 }
115 }
116 return i
117}
118
119/**
120 * The simple commands of a Bash command line, each as its words: split on `&&`, `||`, `;`, `|`,
121 * `&`, newlines and parentheses outside quotes, with comments and heredoc bodies left out.
122 * Quotes, `$( )` and backticks stay inside the word they belong to; offsets are the command's.
123 */
124export const splitCommand = (command: string): Word[][] => {
125 const segments: Word[][] = []
126 let words: Word[] = []
127 let wordStart = -1
128 let pending: { tag: string; strip: boolean }[] = []
129 const s = command
130 const endWord = (at: number) => {
131 if (wordStart >= 0) {
132 words.push({ raw: s.slice(wordStart, at), start: wordStart, end: at })
133 wordStart = -1
134 }
135 }
136 const endSegment = () => {
137 if (words.length > 0) {
138 segments.push(words)
139 }
140 words = []
141 }
142 const begin = (at: number) => {
143 if (wordStart < 0) {
144 wordStart = at
145 }
146 }
147 let i = 0
148 while (i < s.length) {
149 const ch = s[i] ?? ''
150 if (ch === '\\') {
151 if (s[i + 1] === '\n' && wordStart < 0) {
152 i += 2
153 continue
154 }
155 begin(i)
156 i += 2
157 } else if (ch === "'") {
158 begin(i)
159 i = skipSingle(s, i + 1)
160 } else if (ch === '"') {
161 begin(i)
162 i = skipDouble(s, i + 1)
163 } else if (ch === '`') {
164 begin(i)
165 i = skipBacktick(s, i + 1)
166 } else if (ch === '$' && s[i + 1] === '(') {
167 begin(i)
168 i = skipParens(s, i + 2)
169 } else if (ch === '#' && wordStart < 0) {
170 const nl = s.indexOf('\n', i)
171 i = nl < 0 ? s.length : nl
172 } else if (ch === '\n') {
173 endWord(i)
174 endSegment()
175 i++
176 for (const doc of pending) {
177 i = skipHeredoc(s, i, doc)
178 }
179 pending = []
180 } else if (ch === ' ' || ch === '\t' || ch === '\r') {
181 endWord(i)
182 i++
183 } else if (ch === ';' || ch === '|' || ch === '(' || ch === ')') {
184 endWord(i)
185 endSegment()
186 i++
187 } else if (ch === '&') {
188 if (s[i - 1] === '>' || s[i - 1] === '<' || s[i + 1] === '>') {
189 begin(i)
190 i++
191 } else {
192 endWord(i)
193 endSegment()
194 i++
195 }
196 } else if (ch === '<' && s[i + 1] === '<' && s[i + 2] !== '<') {
197 endWord(i)
198 wordStart = i
199 let j = i + 2
200 const strip = s[j] === '-'
201 if (strip) {
202 j++
203 }
204 while (s[j] === ' ' || s[j] === '\t') {
205 j++
206 }
207 let tag = ''
208 while (j < s.length && !/[\s;|&<>()]/.test(s[j] ?? '')) {
209 const c = s[j] ?? ''
210 if (c === "'" || c === '"') {
211 const close = c === "'" ? skipSingle(s, j + 1) : skipDouble(s, j + 1)
212 tag += s.slice(j + 1, close - 1)
213 j = close
214 } else if (c === '\\') {
215 tag += s[j + 1] ?? ''
216 j += 2
217 } else {
218 tag += c
219 j++
220 }
221 }
222 if (tag !== '') {
223 pending.push({ tag, strip })
224 }
225 endWord(j)
226 i = j
227 } else if (ch === '<' || ch === '>') {
228 // A redirection starts its own word unless an fd number (`2>`) or `&` (`&>`) leads it.
229 if (wordStart >= 0 && !/^(?:\d+|&)$/.test(s.slice(wordStart, i))) {
230 endWord(i)
231 }
232 begin(i)
233 i++
234 } else {
235 begin(i)
236 i++
237 }
238 }
239 endWord(Math.min(i, s.length))
240 endSegment()
241 return segments
242}
243
244// ---------------------------------------------------------------------------------------------
245// Expanding words and paths
246
247/** Variables the command assigned before a point: a value, or null when it is not knowable here. */
248export type Vars = ReadonlyMap<string, string | null>
249
250export type Expanded = {
251 /** The word with quotes removed and what is known expanded; what is not stays as written. */
252 value: string
253 /** False when an unknown variable, command substitution or glob leaves the value open. */
254 isResolved: boolean
255}
256
257const NAME = /^[A-Za-z_][A-Za-z0-9_]*/
258
259/** A word's value as the shell would read it: quotes removed, `~`, `$HOME` and the command's own variables expanded. */
260export const expandWord = (raw: string, vars: Vars, home: string | null): Expanded => {
261 let value = ''
262 let isResolved = true
263 let i = 0
264 if (raw === '~' || raw.startsWith('~/')) {
265 if (home) {
266 value = home
267 i = 1
268 } else {
269 isResolved = false
270 }
271 }
272 const substitute = (name: string, written: string) => {
273 const known = !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)
274 ? null
275 : vars.has(name)
276 ? (vars.get(name) ?? null)
277 : name === 'HOME'
278 ? home
279 : null
280 if (known === null) {
281 value += written
282 isResolved = false
283 } else {
284 value += known
285 }
286 }
287 /** Expands the `$` at `j`; returns the index past what it took. */
288 const dollar = (j: number): number => {
289 const next = raw[j + 1] ?? ''
290 if (next === '{') {
291 const close = raw.indexOf('}', j + 2)
292 const end = close < 0 ? raw.length : close + 1
293 substitute(raw.slice(j + 2, close < 0 ? raw.length : close), raw.slice(j, end))
294 return end
295 }
296 if (next === '(') {
297 const end = skipParens(raw, j + 2)
298 value += raw.slice(j, end)
299 isResolved = false
300 return end
301 }
302 const name = NAME.exec(raw.slice(j + 1))?.[0]
303 if (name) {
304 substitute(name, `$${name}`)
305 return j + 1 + name.length
306 }
307 if (/[0-9@*#?$!-]/.test(next)) {
308 value += raw.slice(j, j + 2)
309 isResolved = false
310 return j + 2
311 }
312 value += '$'
313 return j + 1
314 }
315 while (i < raw.length) {
316 const ch = raw[i] ?? ''
317 if (ch === "'") {
318 const close = raw.indexOf("'", i + 1)
319 value += raw.slice(i + 1, close < 0 ? raw.length : close)
320 i = close < 0 ? raw.length : close + 1
321 } else if (ch === '"') {
322 i++
323 while (i < raw.length && raw[i] !== '"') {
324 const c = raw[i] ?? ''
325 if (c === '\\' && '"\\$`\n'.includes(raw[i + 1] ?? 'x')) {
326 value += raw[i + 1] === '\n' ? '' : raw[i + 1]
327 i += 2
328 } else if (c === '$') {
329 i = dollar(i)
330 } else if (c === '`') {
331 const end = skipBacktick(raw, i + 1)
332 value += raw.slice(i, end)
333 isResolved = false
334 i = end
335 } else {
336 value += c
337 i++
338 }
339 }
340 i++
341 } else if (ch === '\\') {
342 value += raw[i + 1] === '\n' ? '' : (raw[i + 1] ?? '')
343 i += 2
344 } else if (ch === '$') {
345 i = dollar(i)
346 } else if (ch === '`') {
347 const end = skipBacktick(raw, i + 1)
348 value += raw.slice(i, end)
349 isResolved = false
350 i = end
351 } else {
352 if (ch === '*' || ch === '?' || ch === '[') {
353 isResolved = false
354 }
355 value += ch
356 i++
357 }
358 }
359 return { value, isResolved }
360}
361
362/** An absolute path with `.`, `..` and repeated slashes folded away. */
363export const normalizePath = (path: string): string => {
364 const parts: string[] = []
365 for (const part of path.split('/')) {
366 if (part === '' || part === '.') {
367 continue
368 }
369 if (part === '..') {
370 parts.pop()
371 } else {
372 parts.push(part)
373 }
374 }
375 return `/${parts.join('/')}`
376}
377
378/** The absolute path a value names from `cwd`; null when it is relative and the directory is unknown. */
379export const resolvePath = (value: string, cwd: string | null): string | null =>
380 value.startsWith('/') ? normalizePath(value) : cwd ? normalizePath(`${cwd}/${value}`) : null
381
382export const baseName = (path: string): string => path.replace(/\/+$/, '').split('/').pop() || path
383
384// ---------------------------------------------------------------------------------------------
385// Finding the slicer opens
386
387/** A file an `open` hands to the slicer. */
388export type OpenFile = {
389 /** The argument as written in the command. */
390 raw: string
391 /** Its value: quotes removed, and what could be expanded expanded. */
392 written: string
393 /** The absolute path it names, or null when a variable, glob or unknown directory leaves it open. */
394 path: string | null
395 /** What an earlier `cp` or `mv` in the same command put there, when one did. */
396 copiedFrom: { written: string; path: string | null } | null
397}
398
399/** One `open` of a slicer in a command. */
400export type SlicerOpen = {
401 slicer: Slicer
402 /** Where the words that choose the app sit (`-a BambuStudio`, `-b <id>`), and the other short flags sharing them. */
403 selector: { start: number; end: number; keep: string }
404 /** Those words as written. */
405 selectorText: string
406 files: OpenFile[]
407}
408
409/** Where a command starts: the session's directory and the home directory, when known. */
410export type Place = { cwd: string | null; home: string | null }
411
412const APPS: Readonly<Record<string, Slicer>> = {
413 bambustudio: 'bambu',
414 snapmakerorca: 'snapmaker',
415 orcaslicer: 'orca',
416}
417
418/** The slicer an `open -a` value names: a name with or without spaces, or an `.app` path. */
419export const slicerOfApp = (app: string): Slicer | null => {
420 const key = baseName(app)
421 .replace(/\.app$/i, '')
422 .toLowerCase()
423 .replace(/[\s_-]+/g, '')
424 return APPS[key] ?? null
425}
426
427/** The slicer an `open -b` bundle id names. */
428export const slicerOfBundle = (bundle: string): Slicer | null => {
429 const id = bundle.trim().toLowerCase()
430 const found = (Object.keys(SLICERS) as Slicer[]).find(slicer => SLICERS[slicer].bundle.toLowerCase() === id)
431 return found ?? null
432}
433
434const KEYWORDS = new Set(['{', '}', '!', 'then', 'do', 'else', 'elif', 'if', 'while', 'until', 'time'])
435const WRAPPERS = new Set(['command', 'exec', 'nohup', 'builtin'])
436const DECLARES = new Set(['export', 'declare', 'readonly', 'local', 'typeset'])
437const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
438/** A redirection word: `>x`, `2>&1`, `&>x`, `<x`, `<<EOF`. */
439const REDIRECT = /^(?:\d+|&)?(?:>>?|<<?<?|>&|<&|>\|)/
440/** A redirection whose target is the next word. */
441const REDIRECT_ALONE = /^(?:\d+|&)?(?:>>?|<|<<<|>\|)$/
442/** Long `open` flags that take the next word. */
443const OPEN_VALUE_FLAGS = new Set(['--env', '--stdin', '--stdout', '--stderr', '--arch'])
444
445type Scope = {
446 vars: Map<string, string | null>
447 cwd: string | null
448 home: string | null
449 copies: Map<string, { written: string; path: string | null }>
450}
451
452function assign(scope: Scope, raw: string) {
453 const eq = raw.indexOf('=')
454 const expanded = expandWord(raw.slice(eq + 1), scope.vars, scope.home)
455 scope.vars.set(raw.slice(0, eq), expanded.isResolved ? expanded.value : null)
456}
457
458const operands = (words: readonly Word[]): Word[] => {
459 const kept: Word[] = []
460 for (let i = 0; i < words.length; i++) {
461 const raw = words[i]?.raw ?? ''
462 if (REDIRECT.test(raw)) {
463 i += REDIRECT_ALONE.test(raw) ? 1 : 0
464 } else if (!raw.startsWith('-')) {
465 kept.push(words[i] as Word)
466 }
467 }
468 return kept
469}
470
471function changeDir(scope: Scope, args: readonly Word[]) {
472 const [target] = operands(args)
473 if (!target) {
474 scope.cwd = scope.home
475 scope.vars.set('PWD', scope.cwd)
476 return
477 }
478 const expanded = expandWord(target.raw, scope.vars, scope.home)
479 scope.cwd = expanded.isResolved && expanded.value !== '-' ? resolvePath(expanded.value, scope.cwd) : null
480 scope.vars.set('PWD', scope.cwd)
481}
482
483function noteCopy(scope: Scope, args: readonly Word[]) {
484 const words = operands(args)
485 const dest = words.pop()
486 if (!dest || words.length === 0) {
487 return
488 }
489 const to = expandWord(dest.raw, scope.vars, scope.home)
490 const destKey = (to.isResolved ? resolvePath(to.value, scope.cwd) : null) ?? to.value
491 for (const source of words) {
492 const from = expandWord(source.raw, scope.vars, scope.home)
493 const copied = { written: from.value, path: from.isResolved ? resolvePath(from.value, scope.cwd) : null }
494 scope.copies.set(`${destKey.replace(/\/+$/, '')}/${baseName(from.value)}`, copied)
495 if (words.length === 1) {
496 scope.copies.set(destKey, copied)
497 }
498 }
499}
500
501function parseOpen(command: string, scope: Scope, args: readonly Word[]): SlicerOpen | null {
502 let selector: { kind: 'a' | 'b'; value: string; start: number; end: number; keep: string } | null = null
503 const fileWords: Word[] = []
504 let isOnlyFiles = false
505 for (let i = 0; i < args.length; i++) {
506 const word = args[i] as Word
507 const raw = word.raw
508 if (raw === '--args') {
509 break
510 }
511 if (REDIRECT.test(raw)) {
512 i += REDIRECT_ALONE.test(raw) ? 1 : 0
513 continue
514 }
515 if (!isOnlyFiles && raw === '--') {
516 isOnlyFiles = true
517 continue
518 }
519 if (!isOnlyFiles && raw.startsWith('--')) {
520 i += OPEN_VALUE_FLAGS.has(raw) ? 1 : 0
521 continue
522 }
523 if (!isOnlyFiles && /^-[A-Za-z]/.test(raw)) {
524 const group = raw.slice(1)
525 for (let k = 0; k < group.length; k++) {
526 const flag = group[k]
527 if (flag === 'a' || flag === 'b') {
528 const attached = group.slice(k + 1)
529 const valueWord = attached ? undefined : args[i + 1]
530 if (attached || valueWord) {
531 selector = {
532 kind: flag,
533 value: expandWord(attached || (valueWord as Word).raw, scope.vars, scope.home).value,
534 start: word.start,
535 end: (valueWord ?? word).end,
536 keep: group.slice(0, k),
537 }
538 i += valueWord ? 1 : 0
539 }
540 break
541 }
542 if (flag === 's') {
543 i += group.slice(k + 1) ? 0 : 1
544 break
545 }
546 }
547 continue
548 }
549 fileWords.push(word)
550 }
551 if (!selector || fileWords.length === 0) {
552 return null
553 }
554 const slicer = selector.kind === 'a' ? slicerOfApp(selector.value) : slicerOfBundle(selector.value)
555 if (!slicer) {
556 return null
557 }
558 const files = fileWords.map((word): OpenFile => {
559 const expanded = expandWord(word.raw, scope.vars, scope.home)
560 const path = expanded.isResolved ? resolvePath(expanded.value, scope.cwd) : null
561 return { raw: word.raw, written: expanded.value, path, copiedFrom: scope.copies.get(path ?? expanded.value) ?? null }
562 })
563 return {
564 slicer,
565 selector: { start: selector.start, end: selector.end, keep: selector.keep },
566 selectorText: command.slice(selector.start, selector.end),
567 files,
568 }
569}
570
571/**
572 * Every `open` of a slicer in a Bash command (`open -a <app>` or `open -b <bundle id>` with one or
573 * more files), in order, with each file resolved as far as the command allows: `~`, `$HOME`, `$PWD`,
574 * variables assigned earlier in it, a preceding `cd`, and what an earlier `cp`/`mv` copied there.
575 */
576export const findSlicerOpens = (command: string, place: Place): SlicerOpen[] => {
577 const scope: Scope = { vars: new Map([['PWD', place.cwd]]), cwd: place.cwd, home: place.home, copies: new Map() }
578 const opens: SlicerOpen[] = []
579 for (const segment of splitCommand(command)) {
580 let words = segment
581 while (words[0] && KEYWORDS.has(words[0].raw)) {
582 words = words.slice(1)
583 }
584 let leading = 0
585 while (words[leading] && ASSIGNMENT.test(words[leading]?.raw ?? '')) {
586 leading++
587 }
588 if (leading === words.length) {
589 for (const word of words) {
590 assign(scope, word.raw)
591 }
592 continue
593 }
594 words = words.slice(leading)
595 while (words[0] && WRAPPERS.has(words[0].raw)) {
596 words = words.slice(1)
597 }
598 const [head, ...args] = words
599 if (!head) {
600 continue
601 }
602 const name = expandWord(head.raw, scope.vars, scope.home).value
603 if (DECLARES.has(name)) {
604 for (const word of args) {
605 if (ASSIGNMENT.test(word.raw)) {
606 assign(scope, word.raw)
607 }
608 }
609 } else if (name === 'cd' || name === 'pushd') {
610 changeDir(scope, args)
611 } else if (name === 'cp' || name === 'mv') {
612 noteCopy(scope, args)
613 } else if (name === 'open' || name === '/usr/bin/open') {
614 const found = parseOpen(command, scope, args)
615 if (found) {
616 opens.push(found)
617 }
618 }
619 }
620 return opens
621}
622
623/** Whether a command could hold an `open` at all: the cheap test before parsing it. */
624export const mentionsOpen = (command: string): boolean => /(?:^|[\s;&|(/])open\s/.test(command)
625
626/**
627 * The command with each of these opens pointed at another slicer: only the words choosing the
628 * app change (`-a BambuStudio` becomes `-b com.snapmaker.snapmaker-orca`); every other byte stays.
629 */
630export const rewriteOpens = (command: string, opens: readonly SlicerOpen[], to: Slicer): string =>
631 [...opens]
632 .sort((a, b) => b.selector.start - a.selector.start)
633 .reduce(
634 (text, open) =>
635 `${text.slice(0, open.selector.start)}${open.selector.keep ? `-${open.selector.keep} ` : ''}-b ${SLICERS[to].bundle}${text.slice(open.selector.end)}`,
636 command,
637 )
638
639// ---------------------------------------------------------------------------------------------
640// Full-spectrum files
641
642/** Whether a file's path (its name or a folder), or the path it was copied from, says full-spectrum. */
643export const nameSaysFullSpectrum = (file: OpenFile, pattern: RegExp): boolean =>
644 [file.written, file.path, file.copiedFrom?.written, file.copiedFrom?.path].some(
645 text => typeof text === 'string' && text !== '' && pattern.test(text),
646 )
647
648/** The resolved 3MF paths whose contents can say: the file itself, then what was copied to it. */
649export const contentPaths = (file: OpenFile): string[] =>
650 [...new Set([file.path, file.copiedFrom?.path])].filter(
651 (path): path is string => typeof path === 'string' && /\.3mf$/i.test(path),
652 )
653
654export const fileName = (file: OpenFile): string => baseName(file.path ?? file.written)
655
656// ---------------------------------------------------------------------------------------------
657// Closing previous instances
658
659/** How many processes `pgrep` listed. */
660export const countPids = (stdout: string): number => stdout.split('\n').filter(line => /^\s*\d+\s*$/.test(line)).length
661
662/** The AppleScript for a normal quit, the same as Cmd-Q: the app still asks to save unsaved work. */
663export const quitScript = (slicer: Slicer): string => `quit app id "${SLICERS[slicer].bundle}"`
664
665// ---------------------------------------------------------------------------------------------
666// /slice
667
668const SLICE_WORDS: Readonly<Record<string, Slicer>> = { bambu: 'bambu', snapmaker: 'snapmaker', snap: 'snapmaker', orca: 'orca' }
669
670/** `/slice <file> [bambu|snapmaker|orca]`: the file, resolved, and the slicer asked for, if one was. */
671export const parseSliceArgs = (args: string, place: Place): { file: OpenFile | null; wanted: Slicer | null } => {
672 const words = splitCommand(args).flat()
673 const last = words.at(-1)
674 const lastValue = last ? expandWord(last.raw, new Map(), place.home).value : ''
675 const wanted = words.length > 1 ? (SLICE_WORDS[lastValue.toLowerCase()] ?? slicerOfApp(lastValue)) : null
676 const fileWords = wanted ? words.slice(0, -1) : words
677 if (fileWords.length === 0) {
678 return { file: null, wanted }
679 }
680 const expanded = fileWords.map(word => expandWord(word.raw, new Map(), place.home))
681 const written = expanded.map(part => part.value).join(' ')
682 const isResolved = expanded.every(part => part.isResolved)
683 return {
684 file: {
685 raw: args.slice(fileWords[0]?.start ?? 0, fileWords.at(-1)?.end ?? args.length),
686 written,
687 path: isResolved ? resolvePath(written, place.cwd) : null,
688 copiedFrom: null,
689 },
690 wanted,
691 }
692}
693
694// ---------------------------------------------------------------------------------------------
695// What the person and the model are told
696
697const shortName = (slicer: Slicer): string => (slicer === 'bambu' ? 'Bambu' : SLICERS[slicer].label)
698
699const listed = (names: readonly string[]): string => names.join(', ')
700
701export const rerouteToast = (from: Slicer, names: readonly string[]): string =>
702 `${shortName(from)} can't open full-spectrum files: opened ${listed(names)} in Snapmaker Orca instead`
703
704export const rerouteNote = (open: SlicerOpen, names: readonly string[]): string =>
705 `slicer-handoff: ${listed(names)} ${names.length === 1 ? 'is a full-spectrum (Snapmaker U1) file' : 'are full-spectrum (Snapmaker U1) files'}, ` +
706 `which ${SLICERS[open.slicer].label} cannot open (it crashes), so \`${open.selectorText}\` was changed to ` +
707 `\`-b ${SLICERS.snapmaker.bundle}\` and the open went to Snapmaker Orca instead. ` +
708 `Full-spectrum files must always be opened in Snapmaker Orca (\`open -b ${SLICERS.snapmaker.bundle} <file>\`), never in Bambu Studio or OrcaSlicer.`
709
710export const closedToast = (slicer: Slicer, closed: number, name: string): string =>
711 `Closed ${closed} ${SLICERS[slicer].label} window(s) before opening ${name}`
712
713export const stillOpenNote = (slicer: Slicer, left: number): string =>
714 `slicer-handoff: ${left === 1 ? 'an instance' : `${left} instances`} of ${SLICERS[slicer].label} ${left === 1 ? 'is' : 'are'} still open after a normal quit request, ` +
715 'most likely asking whether to save unsaved changes. Do not open more windows of it and do not quit or kill it yourself; ' +
716 'tell the user it is waiting for them.'
717