SLOPSHOPPER

cc-mod-park

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

newpanebandcommandtoastprompt
★ 13v0.4.1MITupdated 2026-10-08GGGODLIN/cc-mod-park
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-mod-park
│ ┃ park-list ✕ › fix the failing auth test and add an audit log call │ ┃ No parked sessions │ ⏺ 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 │ │ › /park │ ⎿ cc-mod-park: Parked: fix the failing auth test │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · park-list
No parked sessions
README

cc-mod-park

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.

How it works

  1. /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.
  2. A new session started in the same folder shows an "N parked sessions" button above the prompt.
  3. The button opens a side pane listing the parked sessions, newest first. "Resume" runs /resume in place; "Remove" drops the entry.
  4. After the resume, the model and effort the session had when parked are reported, and your default model and effort in 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:

  • The list matches the folder exactly. A session parked in repo/sub shows up in repo/sub, not in repo.
  • If the parked folder no longer exists (for example, a removed git worktree), the entry shows at its repo root, or else at the nearest existing parent folder.
  • Starting a parked session with 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.
  • The title comes from, in order: your /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.

Why not just /resume

The /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.

Why a mod, not a slash command

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.

Install

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.

Language

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.

Known limits

  • Tested on Claude Code 2.1.288 and 2.1.289, on macOS; loading checked on 2.1.290; /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.
  • If another mod's AbovePrompt handler returns its own element without calling next(e), the park button does not show, with no error.
  • Every session shares one list file, written whole each time. Two sessions parking or resuming in the same moment can drop one of the changes.
  • The list file lives under $HOME, not under CLAUDE_CONFIG_DIR, so separate config folders for the same user share one list.
  • After Resume, your default model and effort in 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.
  • To reach the button by keyboard, press ctrl+x tab to move into the row above the prompt. Focus may land on another mod's button first.
  • After Resume, the old conversation takes a few seconds to appear. The status line shows the restored model only after the next message.

Development

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.

Source 3 files
hooks/register.tsx 333 lines
1import 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}
333
hooks/i18n.ts 140 lines
1export 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}
140
hooks/park.ts 201 lines
1import 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