/park a session you will come back to; a new session in the same folder offers it back with one click.

English | 繁體中文
Park a Claude Code session you will come back to, then pick it up again from a new session in the same folder with one click. Neither step goes through the model.
https://github.com/user-attachments/assets/b186dbce-2c2f-462c-a509-cecfa45d9858
Typical use: it is Friday, the work is not done, and you do not want the session sitting open all weekend or lost after a reboot. Type /park, close the laptop, and on Monday start claude in the same folder.
Keeping a session open is not free. On the author's machine each idle session held about 600 MB of memory (7 sessions, 4.4 GB in total, measured 2026-10-05). And if you run several sessions that message each other, an idle one can be woken to answer, spending a full turn, at full price once its prompt cache has expired.
/park [note] records the session to ~/.local/state/cc-mod-park/parked.json, then exits. An entry holds the session id, its folder and repo root, the git branch, the title, model and effort, and, when the author's recap file exists, its goal / now / next lines. That can include work details, so treat the file like your transcripts./resume in place; "Remove" drops the entry.settings.json are put back if the resume changed them.The button hides once you send your first prompt that is not a slash command, so it stays out of the way of new work.
Details:
repo/sub shows up in repo/sub, not in repo.claude --resume in a new process removes it from the list. Switching to it with the /resume picker inside a session that is already open does not, because a mod gets no start event for that switch; remove the entry by hand./park note, a Herdr pane title, the session's AI title, or the first prompt. The Herdr and recap sources are files from the author's own setup (~/.local/state/herdr-session-title/state.json, ~/.cache/cc-recap/<id>.json); when they are missing they are skipped./resumeThe /resume picker lists every past session, finished or not. A week later you cannot tell which ones you meant to come back to. /park is that decision, made while you still remember it: the list holds only the sessions you marked as unfinished, in the folder you left them, and an entry leaves the list once you pick it up.
A custom slash command (commands/*.md) is a prompt: running it wakes the model and leaves the expansion in the context. Parking a session should not cost a turn, and a resumed session should not carry a "please park me" exchange in its history.
A mod registers /park as a command whose handler answers on its own, the way /cost does, so parking does not start a model turn. The one-line result does stay in the conversation, where the model can read it later. The resume side needs a mod too: the button above the prompt and the side pane are UI that only a mod can draw.
This is a Claude Code mod: a plugin made of function hooks. On Claude Code 2.1.290 mods load without any extra setting (checked 2026-10-06 with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS unset). Install the plugin:
claude plugin marketplace add GGGODLIN/cc-mod-park
claude plugin install cc-mod-park@cc-mod-park
Sessions started after the install load it.
The UI comes in English and Traditional Chinese. The default auto follows the language in Claude Code's settings.json (for example 繁體中文), then LC_ALL / LANG, and falls back to English; any Chinese shows Traditional Chinese. The language is read once, when a session starts. To pin a language, open /config, find this plugin's Language, and pick en or zh-TW.
/park <note> checked on 2.1.292 and 2.1.294. Function hooks are a newer surface of Claude Code, and later versions may change it.AbovePrompt handler returns its own element without calling next(e), the park button does not show, with no error.$HOME, not under CLAUDE_CONFIG_DIR, so separate config folders for the same user share one list.settings.json are put back if they changed. A change another session or you made in that same second is put back too, since the file cannot tell who changed it. If settings.json cannot be read, nothing is written and the result line says so./park typed while the model is still answering gets queued and then dropped. Wait for the turn to end, then type it.ctrl+x tab to move into the row above the prompt. Focus may land on another mod's button first.bun test
To try local changes, bump version in .claude-plugin/plugin.json and run claude plugin update cc-mod-park@cc-mod-park. The install is a cached copy, so edits to the working tree do not take effect on their own.
hooks/register.tsx 333 lines1import type { EngineInterface, Register } from 'claude-code'
2import { pickLocale, stringsFor, type Strings } from './i18n.ts'
3import {
4 HERDR_TITLE_FILE,
5 RECAP_DIR,
6 STATE_FILE,
7 aiTitleOf,
8 ageText,
9 defaultsBefore,
10 defaultsOf,
11 firstPromptOf,
12 herdrTitleOf,
13 isNewWork,
14 modelLine,
15 parseList,
16 pickTitle,
17 projectFolder,
18 recapOf,
19 restoreReport,
20 sameDefaults,
21 serialize,
22 shownFor,
23 upsert,
24 without,
25 writeBackPlan,
26 type Defaults,
27 type Entry,
28 type SettingsRead,
29 type Shown,
30} from './park.ts'
31
32const PANE = 'park-list'
33// $.command.run is refused inside a command.run hook (it would wait on the held turn); a short timer runs it after
34const DEFER_MS = 300
35const SETTLE_MS = 800
36// English until session.start has read the language settings
37let ui: Strings = stringsFor('en')
38
39const readOrNull = async ($: EngineInterface, path: string) => {
40 try {
41 return (await $.fs.exists(path)) ? await $.fs.read(path) : null
42 } catch {
43 return null
44 }
45}
46
47const git = async ($: EngineInterface, cwd: string, args: readonly string[]) => {
48 try {
49 const run = await $.process.run(['git', ...args], { cwd, timeoutMs: 5_000 })
50 return run.exitCode === 0 ? run.stdout.trim() || null : null
51 } catch {
52 return null
53 }
54}
55
56const ancestors = (path: string) => {
57 const out: string[] = []
58 let dir = path
59 while (dir.lastIndexOf('/') > 0) {
60 dir = dir.slice(0, dir.lastIndexOf('/'))
61 out.push(dir)
62 }
63 return out
64}
65
66const loadList = async ($: EngineInterface, path: string) => parseList(await readOrNull($, path))
67
68const saveList = async ($: EngineInterface, path: string, list: readonly Entry[]) => {
69 await $.process.run(['mkdir', '-p', path.slice(0, path.lastIndexOf('/'))])
70 await $.fs.write(path, serialize(list))
71}
72
73const shownNow = async ($: EngineInterface, list: readonly Entry[], cwd: string) => {
74 const paths = new Set(list.flatMap((entry) => [entry.dir, ...(entry.repoRoot === null ? [] : [entry.repoRoot]), ...ancestors(entry.dir)]))
75 const existing = new Set<string>()
76 for (const path of paths) if (await $.fs.exists(path)) existing.add(path)
77 return shownFor(list, cwd, (path) => existing.has(path))
78}
79
80const closeList = ($: EngineInterface) => {
81 void $.ui.close({ id: PANE })
82 $.ui.invalidate('ui.render')
83}
84
85// The error text, or null when the command ran
86const runError = async ($: EngineInterface, command: string, args: string) => {
87 try {
88 await $.command.run({ command, args })
89 return null
90 } catch (error) {
91 return String(error)
92 }
93}
94
95// In-place /resume does not bring back the parked session's model or effort, so set both after it lands
96// settings.json is usually a symlink into ~/.claude; writing the link path could replace the link with a plain file
97const settingsFile = async ($: EngineInterface, home: string) => {
98 const link = `${(await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${home}/.claude`}/settings.json`
99 const real = await $.process.run(['realpath', link], { timeoutMs: 5_000 })
100 return real.exitCode === 0 ? real.stdout.trim() : link
101}
102
103const readSettings = async ($: EngineInterface, path: string): Promise<SettingsRead> => {
104 try {
105 if (!(await $.fs.exists(path))) return { kind: 'missing' }
106 return { kind: 'ok', value: JSON.parse(await $.fs.read(path)) }
107 } catch {
108 return { kind: 'unreadable' }
109 }
110}
111
112// Writing back a default leaves the running session alone (checked 2026-10-04: an outside edit of "model" did not switch the open session)
113// Another writer changing model or effort in the same window is also put back: the file alone cannot tell who changed it
114const putDefaultsBack = async ($: EngineInterface, path: string, model: string | null, before: Defaults | null): Promise<string> => {
115 // /effort and /resume save after they reply; reading too early would see the old values and skip the repair
116 await $.clock.sleep(SETTLE_MS)
117 const plan = writeBackPlan(before, await readSettings($, path), model, ui)
118 if (plan.write === null) return plan.line
119 try {
120 await $.fs.write(path, `${JSON.stringify(plan.write, null, 2)}\n`)
121 } catch (error) {
122 return ui.defaultsWriteFailed(String(error))
123 }
124 const reread = await readSettings($, path)
125 const check = defaultsOf(reread.kind === 'ok' ? reread.value : null, model)
126 return before !== null && sameDefaults(before, check)
127 ? ui.defaultsRestored(before.model ?? ui.notSet, model ?? ui.notSet, before.effort ?? ui.notSet)
128 : ui.defaultsRestoreFailed(check.model ?? ui.notSet, check.effort ?? ui.notSet)
129}
130
131// The entry leaves the list only once this session is the parked one; a stale entry costs a click, a lost one loses the bookmark
132const dropIfLanded = async ($: EngineInterface, entry: Entry, statePath: string): Promise<string | null> => {
133 const landed = await $.session.id()
134 if (landed !== entry.id) return ui.wrongSession(landed)
135 try {
136 await saveList($, statePath, without(await loadList($, statePath), entry.id))
137 return null
138 } catch (error) {
139 return ui.dropFailed(String(error))
140 }
141}
142
143// Throws only when /resume itself fails, so the caller keeps the entry; later failures become report lines
144const resumeAndRestore = async ($: EngineInterface, entry: Entry, home: string, statePath: string) => {
145 const path = await settingsFile($, home)
146 const before = defaultsBefore(await readSettings($, path), entry.model)
147 await $.command.run({ command: 'resume', args: entry.id })
148 if (entry.model !== null && (await $.session.model()) !== entry.model) await runError($, 'model', entry.model)
149 const effortError = entry.effort === null ? null : await runError($, 'effort', entry.effort)
150 const report = restoreReport(entry, { model: await $.session.model(), effortError }, ui)
151 const defaultsLine = await putDefaultsBack($, path, entry.model, before)
152 const listLine = await dropIfLanded($, entry, statePath)
153 const lines = [ui.resumed(entry.title), ...report, defaultsLine, ...(listLine === null ? [] : [listLine])]
154 // One log call per line: a newline inside one entry renders as a replacement glyph in the transcript
155 for (const line of lines) $.ui.log(line)
156}
157
158export const register: Register = (on, options) => {
159 let home: string | null = null
160 let cwd = ''
161 let shown: Shown[] = []
162 // In memory on purpose: hiding is for this session only, the list on disk stays
163 let active = false
164 // The API has no effort getter; the last main-loop request carries it
165 let lastEffort: string | null = null
166 // Checked on the first request after a resume: the setting the model actually got, not what /effort replied
167 let pendingCheck: { model: string | null; effort: string | null } | null = null
168
169 let statePath = ''
170
171 on('session.start', async ($, e, next) => {
172 home = (await $.env.get('HOME')) ?? null
173 if (home === null) return next(e)
174 statePath = `${home}/${STATE_FILE}`
175 // An empty LC_ALL means unset to the C library, so it must not hide LANG
176 const lcAll = await $.env.get('LC_ALL')
177 const envLang = lcAll !== undefined && lcAll !== '' ? lcAll : await $.env.get('LANG')
178 const settings = await readSettings($, await settingsFile($, home))
179 const claudeLanguage = settings.kind === 'ok' ? (settings.value as { language?: unknown } | null)?.language : undefined
180 ui = stringsFor(pickLocale({ option: options.language, claudeLanguage, envLang }))
181 await $.command.register({ name: 'park', description: ui.commandDescription, argumentHint: ui.argumentHint })
182 try {
183 cwd = await $.session.cwd()
184 let list = await loadList($, statePath)
185 // Our own id still listed means this session came back some other way (claude --resume, the /resume picker)
186 const self = await $.session.id()
187 if (list.some((entry) => entry.id === self)) {
188 list = without(list, self)
189 await saveList($, statePath, list)
190 }
191 shown = await shownNow($, list, cwd)
192 active = shown.length > 0
193 $.ui.invalidate('ui.render')
194 } catch (error) {
195 $.ui.log(`park: start failed ${JSON.stringify(String(error))}`, { to: 'debug' })
196 }
197 return next(e)
198 })
199
200 on('command.run', { command: 'park' }, async ($, e) => {
201 if (home === null) return { text: ui.noHome }
202 const id = await $.session.id()
203 const dir = await $.session.cwd()
204 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${home}/.claude`
205 const commonDir = await git($, dir, ['rev-parse', '--path-format=absolute', '--git-common-dir'])
206 const transcript = (await readOrNull($, `${configDir}/projects/${projectFolder(await $.session.root())}/${id}.jsonl`)) ?? (await readOrNull($, `${configDir}/projects/${projectFolder(dir)}/${id}.jsonl`))
207 let firstPrompt: string | null = null
208 try {
209 firstPrompt = firstPromptOf(await $.session.messages())
210 } catch {}
211 const entry: Entry = {
212 id,
213 dir,
214 repoRoot: commonDir?.endsWith('/.git') ? commonDir.slice(0, -'/.git'.length) : null,
215 branch: await git($, dir, ['branch', '--show-current']),
216 title: pickTitle({
217 note: e.args,
218 herdrTitle: herdrTitleOf(await readOrNull($, `${home}/${HERDR_TITLE_FILE}`), id),
219 aiTitle: aiTitleOf(transcript),
220 firstPrompt,
221 }, ui),
222 ...recapOf(await readOrNull($, `${home}/${RECAP_DIR}/${id}.json`)),
223 model: (await $.session.model()).trim() === '' ? null : await $.session.model(),
224 effort: lastEffort,
225 parkedAt: await $.clock.now(),
226 }
227 try {
228 await saveList($, statePath, upsert(await loadList($, statePath), entry))
229 } catch (error) {
230 return { text: ui.writeFailed(String(error)) }
231 }
232 $.clock.after(DEFER_MS, () => void $.command.run({ command: 'exit' }))
233 return { text: ui.parked(entry.title) }
234 })
235
236 on('turn.step', async function* ($, e, next) {
237 if (e.agentId === undefined) {
238 if (e.effort !== undefined) lastEffort = String(e.effort)
239 if (pendingCheck !== null) {
240 const wanted = pendingCheck
241 pendingCheck = null
242 const effort = e.effort === undefined ? ui.noEffort : String(e.effort)
243 const ok = (wanted.model === null || wanted.model === e.model) && (wanted.effort === null || wanted.effort === effort)
244 $.ui.log(ui.firstRequest(e.model, effort, ok ? null : modelLine(wanted.model, wanted.effort, ui)))
245 }
246 }
247 return yield* next(e)
248 })
249
250 on('prompt.submit', async ($, e, next) => {
251 if (active && (e.origin.kind === 'composer' || e.origin.kind === 'bridge') && isNewWork(e.text)) {
252 active = false
253 closeList($)
254 }
255 return next(e)
256 })
257
258 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
259 if (!active || e.props.hasSurvey || e.surface !== 'terminal' || shown.length === 0) return next(e)
260 const { Box, Button } = await $.ui.resolve(e)
261 return (
262 <Box flexDirection="column">
263 <Box flexDirection="row">
264 <Button key="park:open" label={ui.button(shown.length)} onPress={() => void $.ui.open({ id: PANE, title: ui.paneTitle, focus: true, closeOnEscape: true })} />
265 </Box>
266 {await next(e)}
267 </Box>
268 )
269 })
270
271 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
272 const { Box, Button, Text } = await $.ui.resolve(e)
273 const now = await $.clock.now()
274
275 // Re-read before acting: another session in the same folder may have taken the entry already
276 const resume = (entry: Shown) => {
277 void (async () => {
278 const list = await loadList($, statePath)
279 if (!list.some((one) => one.id === entry.id)) {
280 $.ui.toast(ui.alreadyTaken)
281 shown = await shownNow($, list, cwd)
282 $.ui.invalidate('ui.render')
283 return
284 }
285 // The entry leaves the list only after the resume lands (in resumeAndRestore), so a failed one keeps the bookmark
286 active = false
287 closeList($)
288 $.clock.after(DEFER_MS, () =>
289 void resumeAndRestore($, entry, home ?? '', statePath).then(
290 () => {
291 pendingCheck = { model: entry.model, effort: entry.effort }
292 },
293 (error) => $.ui.log(ui.resumeFailed(String(error))),
294 ),
295 )
296 })()
297 }
298
299 const remove = (entry: Shown) => {
300 void (async () => {
301 const list = without(await loadList($, statePath), entry.id)
302 await saveList($, statePath, list)
303 shown = await shownNow($, list, cwd)
304 if (shown.length > 0) return $.ui.invalidate('ui.render')
305 active = false
306 closeList($)
307 })()
308 }
309
310 if (shown.length === 0) return <Text dimColor>{ui.noParked}</Text>
311 return (
312 <Box flexDirection="column" rowGap={1}>
313 {shown.map((entry) => (
314 <Box key={`park:${entry.id}`} flexDirection="column">
315 <Text bold wrap="truncate-end">{entry.title}</Text>
316 {entry.goal === null ? null : <Text dimColor wrap="truncate-end">{`goal ${entry.goal}`}</Text>}
317 {entry.now === null ? null : <Text dimColor wrap="truncate-end">{`now ${entry.now}`}</Text>}
318 {entry.next === null ? null : <Text dimColor wrap="truncate-end">{`next ${entry.next}`}</Text>}
319 {modelLine(entry.model, entry.effort, ui) === null ? null : <Text dimColor wrap="truncate-end">{`model ${modelLine(entry.model, entry.effort, ui)}`}</Text>}
320 <Text dimColor wrap="truncate-end">
321 {[ageText(entry.parkedAt, now, ui), entry.branch === null ? null : `⎇ ${entry.branch}`, entry.dirGone ? ui.dirGone(entry.dir) : null].filter((part) => part !== null).join(' · ')}
322 </Text>
323 <Box flexDirection="row" columnGap={1}>
324 <Button key={`park:${entry.id}:resume`} label={ui.resume} onPress={() => resume(entry)} />
325 <Button key={`park:${entry.id}:remove`} label={ui.remove} dimColor onPress={() => remove(entry)} />
326 </Box>
327 </Box>
328 ))}
329 </Box>
330 )
331 })
332}
333hooks/i18n.ts 140 lines1export type Locale = 'en' | 'zh-TW'
2
3export type Strings = {
4 commandDescription: string
5 argumentHint: string
6 noHome: string
7 writeFailed: (error: string) => string
8 parked: (title: string) => string
9 untitled: string
10 button: (n: number) => string
11 paneTitle: string
12 noParked: string
13 resume: string
14 remove: string
15 alreadyTaken: string
16 dirGone: (dir: string) => string
17 minutesAgo: (n: number) => string
18 hoursAgo: (n: number) => string
19 daysAgo: (n: number) => string
20 modelNotRecorded: string
21 effortNotRecorded: string
22 resumed: (title: string) => string
23 resumeFailed: (error: string) => string
24 modelNotRestored: string
25 modelCheck: (wanted: string, actual: string | null, ok: boolean) => string
26 effortNotRestored: string
27 effortFailed: (effort: string, error: string) => string
28 effortSet: (effort: string) => string
29 firstRequest: (model: string, effort: string, mismatch: string | null) => string
30 noEffort: string
31 notSet: string
32 defaultsUnknownBefore: string
33 defaultsUnreadable: string
34 defaultsUnchanged: string
35 defaultsWriteFailed: (error: string) => string
36 defaultsRestored: (model: string, modelName: string, effort: string) => string
37 defaultsRestoreFailed: (model: string, effort: string) => string
38 wrongSession: (landed: string) => string
39 dropFailed: (error: string) => string
40}
41
42const STRINGS: Record<Locale, Strings> = {
43 en: {
44 commandDescription: 'Park this session; resume it later from a new session in the same folder',
45 argumentHint: '[note]',
46 noHome: 'park: HOME is not set, nothing parked',
47 writeFailed: error => `park: could not write the list, nothing parked: ${error}`,
48 parked: title => `Parked: ${title}`,
49 untitled: '(untitled session)',
50 button: n => `${n} parked session${n === 1 ? '' : 's'}`,
51 paneTitle: 'Parked sessions',
52 noParked: 'No parked sessions',
53 resume: 'Resume',
54 remove: 'Remove',
55 alreadyTaken: 'Already resumed or removed',
56 dirGone: dir => `folder no longer exists: ${dir}`,
57 minutesAgo: n => `${n} min ago`,
58 hoursAgo: n => `${n} h ago`,
59 daysAgo: n => `${n} d ago`,
60 modelNotRecorded: '(model not recorded)',
61 effortNotRecorded: '(effort not recorded)',
62 resumed: title => `Resumed: ${title}`,
63 resumeFailed: error => `Resume failed: ${error}; the entry is kept`,
64 modelNotRestored: 'model: not recorded when parked, not restored',
65 modelCheck: (wanted, actual, ok) => `model: wanted ${wanted}, now ${actual ?? 'unknown'} ${ok ? '✓' : '✗'}`,
66 effortNotRestored: 'effort: not recorded when parked, not restored',
67 effortFailed: (effort, error) => `effort: /effort ${effort} failed (${error}), not restored ✗`,
68 effortSet: effort => `effort: set to ${effort} with /effort, confirmed on your next message`,
69 firstRequest: (model, effort, mismatch) => `First request after resume used: ${model} · ${effort} ${mismatch === null ? '✓ same as when parked' : `✗ parked with ${mismatch}`}`,
70 noEffort: '(none)',
71 notSet: '(not set)',
72 defaultsUnknownBefore: 'Defaults: settings.json was unreadable before the resume, so changes were not checked',
73 defaultsUnreadable: 'Defaults: settings.json is unreadable, nothing put back; check your /model and /effort defaults',
74 defaultsUnchanged: 'Defaults: unchanged',
75 defaultsWriteFailed: error => `Defaults: writing back failed (${error}); check your /model and /effort defaults ✗`,
76 defaultsRestored: (model, modelName, effort) => `Defaults: the resume changed them; put back default model: ${model}; effort for ${modelName}: ${effort} ✓`,
77 defaultsRestoreFailed: (model, effort) => `Defaults: putting back failed; now model ${model}, effort ${effort} ✗`,
78 wrongSession: landed => `Parked list: this session is ${landed}, not the parked one; entry kept`,
79 dropFailed: error => `Parked list: could not remove this entry (${error}); press Remove later`,
80 },
81 'zh-TW': {
82 commandDescription: '停泊這個 session,之後在同目錄開新 session 可接回',
83 argumentHint: '[備註]',
84 noHome: 'park: 讀不到 HOME,沒有停泊',
85 writeFailed: error => `park: 寫入清單失敗,沒有停泊:${error}`,
86 parked: title => `已停泊:${title}`,
87 untitled: '(未命名 session)',
88 button: n => `${n} 個停泊的 session`,
89 paneTitle: '停泊的 session',
90 noParked: '沒有停泊的 session',
91 resume: '接回',
92 remove: '移除',
93 alreadyTaken: '這筆已被接回或移除',
94 dirGone: dir => `原目錄已不存在:${dir}`,
95 minutesAgo: n => `${n} 分鐘前`,
96 hoursAgo: n => `${n} 小時前`,
97 daysAgo: n => `${n} 天前`,
98 modelNotRecorded: '(model 未記錄)',
99 effortNotRecorded: '(effort 未記錄)',
100 resumed: title => `已接回:${title}`,
101 resumeFailed: error => `接回失敗:${error};停泊紀錄保留`,
102 modelNotRestored: 'model:停泊時沒記錄,未還原',
103 modelCheck: (wanted, actual, ok) => `model:要 ${wanted},現在 ${actual ?? '讀不到'} ${ok ? '✓' : '✗'}`,
104 effortNotRestored: 'effort:停泊時沒記錄,未還原',
105 effortFailed: (effort, error) => `effort:/effort ${effort} 失敗(${error}),未還原 ✗`,
106 effortSet: effort => `effort:已用 /effort 設為 ${effort},送出下一則訊息時確認`,
107 firstRequest: (model, effort, mismatch) => `接回後第一則請求實際用:${model} · ${effort} ${mismatch === null ? '✓ 與停泊時一致' : `✗ 停泊時是 ${mismatch}`}`,
108 noEffort: '(無)',
109 notSet: '(未設定)',
110 defaultsUnknownBefore: '預設設定:接回前讀不到 settings.json,沒有檢查是否被改動',
111 defaultsUnreadable: '預設設定:讀不到 settings.json,沒有改回;請自己確認 /model 與 /effort 的預設',
112 defaultsUnchanged: '預設設定:沒被改動',
113 defaultsWriteFailed: error => `預設設定:寫回失敗(${error}),請自己確認 /model 與 /effort 的預設 ✗`,
114 defaultsRestored: (model, modelName, effort) => `預設設定:接回改了它,已改回預設 model:${model};${modelName} 的 effort:${effort} ✓`,
115 defaultsRestoreFailed: (model, effort) => `預設設定:改回失敗,現在 model ${model}、effort ${effort} ✗`,
116 wrongSession: landed => `停泊清單:目前 session 是 ${landed},不是停泊的那一筆,紀錄保留`,
117 dropFailed: error => `停泊清單:沒能移除這一筆(${error}),之後可按「移除」`,
118 },
119}
120
121export const stringsFor = (locale: Locale): Strings => STRINGS[locale]
122
123const CHINESE = /中文|漢語|汉语|華語|华语|國語|国语|chinese|mandarin|^zh(?:[-_.\s]|$)/i
124const ENGLISH = /英文|英語|english|^en(?:[-_.\s]|$)/i
125
126/**
127 * The UI language: the mod's own setting when it names one, then Claude Code's free-text
128 * `language` setting, then LC_ALL / LANG; English when none of them says. Any Chinese maps to
129 * Traditional Chinese, the only Chinese the mod ships.
130 */
131export function pickLocale(input: { option: unknown; claudeLanguage: unknown; envLang: string | undefined }): Locale {
132 if (input.option === 'en' || input.option === 'zh-TW') return input.option
133 if (typeof input.claudeLanguage === 'string') {
134 const language = input.claudeLanguage.trim()
135 if (CHINESE.test(language)) return 'zh-TW'
136 if (ENGLISH.test(language)) return 'en'
137 }
138 return /^zh/i.test(input.envLang ?? '') ? 'zh-TW' : 'en'
139}
140hooks/park.ts 201 lines1import type { Strings } from './i18n.ts'
2
3export const STATE_FILE = '.local/state/cc-mod-park/parked.json'
4export const HERDR_TITLE_FILE = '.local/state/herdr-session-title/state.json'
5export const RECAP_DIR = '.cache/cc-recap'
6export const PROMPT_TITLE_CHARS = 40
7
8export type Entry = {
9 id: string
10 dir: string
11 // Main repo root, so a worktree parked and later removed still shows under the repo
12 repoRoot: string | null
13 branch: string | null
14 title: string
15 goal: string | null
16 now: string | null
17 next: string | null
18 // Null on entries parked before 0.2.0, or before the session sent any request (no effort seen yet)
19 model: string | null
20 effort: string | null
21 parkedAt: number
22}
23
24export type Shown = Entry & { dirGone: boolean }
25
26const str = (value: unknown) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : null)
27
28export const parseList = (text: string | null): Entry[] => {
29 if (text === null) return []
30 try {
31 const raw: unknown = JSON.parse(text)
32 if (!Array.isArray(raw)) return []
33 return raw.flatMap((item): Entry[] => {
34 const id = str(item?.id)
35 const dir = str(item?.dir)
36 const title = str(item?.title)
37 if (id === null || dir === null || title === null || typeof item.parkedAt !== 'number') return []
38 return [{ id, dir, repoRoot: str(item.repoRoot), branch: str(item.branch), title, goal: str(item.goal), now: str(item.now), next: str(item.next), model: str(item.model), effort: str(item.effort), parkedAt: item.parkedAt }]
39 })
40 } catch {
41 return []
42 }
43}
44
45export const serialize = (list: readonly Entry[]) => `${JSON.stringify(list, null, 2)}\n`
46
47export const upsert = (list: readonly Entry[], entry: Entry): Entry[] => [...list.filter((one) => one.id !== entry.id), entry]
48
49export const without = (list: readonly Entry[], id: string): Entry[] => list.filter((one) => one.id !== id)
50
51const parentOf = (path: string) => {
52 const cut = path.replace(/\/+$/, '').lastIndexOf('/')
53 return cut <= 0 ? '/' : path.slice(0, cut)
54}
55
56// A gone directory falls back to its repo root, else to its nearest surviving ancestor,
57// so it shows in one predictable place instead of nowhere.
58export const homeDir = (entry: Entry, exists: (path: string) => boolean): { dir: string; dirGone: boolean } => {
59 if (exists(entry.dir)) return { dir: entry.dir, dirGone: false }
60 if (entry.repoRoot !== null && exists(entry.repoRoot)) return { dir: entry.repoRoot, dirGone: true }
61 let dir = parentOf(entry.dir)
62 while (dir !== '/' && !exists(dir)) dir = parentOf(dir)
63 return { dir, dirGone: true }
64}
65
66export const shownFor = (list: readonly Entry[], cwd: string, exists: (path: string) => boolean): Shown[] =>
67 list
68 .flatMap((entry) => {
69 const home = homeDir(entry, exists)
70 return home.dir === cwd ? [{ ...entry, dirGone: home.dirGone }] : []
71 })
72 .sort((a, b) => b.parkedAt - a.parkedAt)
73
74const clip = (text: string, chars: number) => {
75 const flat = text.replace(/\s+/g, ' ').trim()
76 const units = [...flat]
77 return units.length > chars ? `${units.slice(0, chars).join('')}…` : flat
78}
79
80export type TitleSources = { note: string | null; herdrTitle: string | null; aiTitle: string | null; firstPrompt: string | null }
81
82export const pickTitle = (sources: TitleSources, ui: Strings): string => {
83 const prompt = str(sources.firstPrompt)
84 return str(sources.note) ?? str(sources.herdrTitle) ?? str(sources.aiTitle) ?? (prompt === null ? null : clip(prompt, PROMPT_TITLE_CHARS)) ?? ui.untitled
85}
86
87export const herdrTitleOf = (stateText: string | null, id: string): string | null => {
88 if (stateText === null) return null
89 try {
90 return str(JSON.parse(stateText)?.[id]?.name)
91 } catch {
92 return null
93 }
94}
95
96export const recapOf = (text: string | null): { goal: string | null; now: string | null; next: string | null } => {
97 if (text === null) return { goal: null, now: null, next: null }
98 try {
99 const raw = JSON.parse(text)
100 return { goal: str(raw?.goal), now: str(raw?.now), next: str(raw?.next) }
101 } catch {
102 return { goal: null, now: null, next: null }
103 }
104}
105
106// Last ai-title wins: Claude Code appends a new one each time it retitles
107export const aiTitleOf = (transcript: string | null): string | null => {
108 if (transcript === null) return null
109 let title: string | null = null
110 for (const line of transcript.split('\n')) {
111 if (!line.includes('"ai-title"')) continue
112 try {
113 const row = JSON.parse(line)
114 if (row?.type === 'ai-title') title = str(row.aiTitle) ?? title
115 } catch {}
116 }
117 return title
118}
119
120// Claude Code names a project folder after its directory with every non-alphanumeric character as '-'
121export const projectFolder = (dir: string) => dir.replace(/[^a-zA-Z0-9]/g, '-')
122
123// Tool results and injected tags also arrive as user messages; a real prompt is plain text
124export const firstPromptOf = (messages: readonly { role: string; text: string }[]): string | null =>
125 messages.find((m) => m.role === 'user' && m.text.trim().length > 0 && !m.text.trimStart().startsWith('<'))?.text ?? null
126
127export const ageText = (parkedAt: number, now: number, ui: Strings): string => {
128 const minutes = Math.max(0, Math.floor((now - parkedAt) / 60_000))
129 if (minutes < 60) return ui.minutesAgo(minutes)
130 const hours = Math.floor(minutes / 60)
131 if (hours < 24) return ui.hoursAgo(hours)
132 return ui.daysAgo(Math.floor(hours / 24))
133}
134
135export const modelLine = (model: string | null, effort: string | null, ui: Strings): string | null =>
136 model === null && effort === null ? null : [model ?? ui.modelNotRecorded, effort ?? ui.effortNotRecorded].join(' · ')
137
138// One line per setting so a mismatch is visible at a glance after resume
139export const restoreReport = (wanted: { model: string | null; effort: string | null }, actual: { model: string | null; effortError: string | null }, ui: Strings): string[] => [
140 wanted.model === null ? ui.modelNotRestored : ui.modelCheck(wanted.model, actual.model, actual.model === wanted.model),
141 // /effort run from a plugin returns no text, so the first request after resume is where effort is confirmed
142 wanted.effort === null
143 ? ui.effortNotRestored
144 : actual.effortError !== null
145 ? ui.effortFailed(wanted.effort, actual.effortError)
146 : ui.effortSet(wanted.effort),
147]
148
149// The two settings a resume can overwrite: /resume saves the session's model as the default model,
150// and /effort saves the level as that model's default
151export type Defaults = { model: string | null; effort: string | null }
152
153type Json = Record<string, unknown>
154const asObject = (value: unknown): Json => (typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Json) : {})
155
156export const defaultsOf = (settings: unknown, model: string | null): Defaults => {
157 const root = asObject(settings)
158 const perModel = model === null ? {} : asObject(asObject(root.modelSettings)[model])
159 return { model: str(root.model), effort: str(perModel.effortLevel) }
160}
161
162// Puts back exactly what was there: a key absent before the resume is removed, not set to null
163export const withDefaults = (settings: unknown, model: string | null, wanted: Defaults): Json => {
164 const original = asObject(settings)
165 const { model: _dropped, ...withoutModel } = original
166 const root: Json = wanted.model === null ? withoutModel : { ...original, model: wanted.model }
167 if (model === null) return root
168 const all = asObject(root.modelSettings)
169 const { effortLevel: _droppedEffort, ...otherFields } = asObject(all[model])
170 const entry: Json = wanted.effort === null ? otherFields : { ...asObject(all[model]), effortLevel: wanted.effort }
171 const { [model]: _droppedEntry, ...otherModels } = all
172 if (Object.keys(entry).length > 0) return { ...root, modelSettings: { ...all, [model]: entry } }
173 // Left empty, modelSettings was most likely created by /effort during the resume; an empty one written by hand goes too, which changes no setting
174 if (Object.keys(otherModels).length > 0) return { ...root, modelSettings: otherModels }
175 const { modelSettings: _droppedAll, ...withoutModelSettings } = root
176 return withoutModelSettings
177}
178
179export const sameDefaults = (a: Defaults, b: Defaults) => a.model === b.model && a.effort === b.effort
180
181// A failed read must not look like an empty file: writing back over "nothing" would wipe every other setting
182export type SettingsRead = { kind: 'ok'; value: unknown } | { kind: 'missing' } | { kind: 'unreadable' }
183
184const isObject = (value: unknown) => typeof value === 'object' && value !== null && !Array.isArray(value)
185
186// Null means unknown, so nothing after the resume can be judged against it
187export const defaultsBefore = (read: SettingsRead, model: string | null): Defaults | null => {
188 if (read.kind === 'missing') return { model: null, effort: null }
189 if (read.kind === 'unreadable' || !isObject(read.value)) return null
190 return defaultsOf(read.value, model)
191}
192
193export const writeBackPlan = (before: Defaults | null, now: SettingsRead, model: string | null, ui: Strings): { write: null; line: string } | { write: Json; line: null } => {
194 if (before === null) return { write: null, line: ui.defaultsUnknownBefore }
195 if (now.kind !== 'ok' || !isObject(now.value)) return { write: null, line: ui.defaultsUnreadable }
196 if (sameDefaults(before, defaultsOf(now.value, model))) return { write: null, line: ui.defaultsUnchanged }
197 return { write: withDefaults(now.value, model, before), line: null }
198}
199
200export const isNewWork = (text: string) => text.trim().length > 0 && !text.trimStart().startsWith('/')
201