Your job search in a panel next to Claude Code: an application funnel that flags silent companies, jobs Claude finds and ranks by fit with your resume, and a…

Your job search, docked next to Claude Code. Track every application, let Claude find jobs that fit your resume, and tailor the resume for each company. Everything stays in plain JSON in your own folder.

At the Claude Code prompt:
/plugin install job-hunt --marketplace vlle/job-hunt-cc
Answer y to add the marketplace, then pick a scope: user loads it in every session. From a shell it is claude plugin install job-hunt --marketplace vlle/job-hunt-cc. In the desktop app, click + next to the prompt box, then Plugins, Add marketplace with vlle/job-hunt-cc, then Add plugin.
You need Claude Code 2.1.300 or later (claude --version, claude update).
/hunt demo
The panel opens with a made-up search: seven applications at every stage, a silent one flagged, six ranked jobs. Nothing is written. Run /hunt demo again, or press p in the panel, to switch back.
claude./hunt init. Claude finds your resume (PDF, DOCX, Markdown or a LinkedIn export) and writes resume.txt, using only facts from it. Already have a resume.txt? Skip this step: the panel opens on its own.s in the panel. Claude searches the web for jobs that match your resume, opens each posting to check it is still open, and ranks the results by fit.a on a job once you have applied, and n when a company moves you forward. Or just tell Claude: "Acme invited me to an interview".sent › screen › interview › offer, one row per active application with its stage and age. A ⚑ flags any company that has been silent for 21 days.⌂ marks a fully remote job you can do from your country (as an employee, contractor, B2B or through an EOR); ✈ marks explicit visa sponsorship or relocation.Searching and tailoring are ordinary Claude turns that the panel queues for you, so they use your session's own tools (web search, scrapers, MCP servers). When a search finishes you get a notification through your terminal's channel (iTerm2, kitty, Ghostty or a bell), the same way Claude Code notifies you. The mod itself only reads and writes a few files in your folder.
| Key | Where | What it does |
|---|---|---|
1–9 | panel | select a row |
j / k, arrows | panel | move the selection (after clicking into the panel) |
s | panel | ask Claude to search for jobs |
a | job | log an application: the job moves to APPLIED |
t | job | ask Claude to tailor the resume for this job |
x | job | hide the job |
o | job or application | open the posting in the browser |
n | application | move to the next stage |
r / c | application | mark as rejected / close |
p | panel | swap in made-up demo data (for screenshots), and back |
/hunt opens the panel. The same actions work by row number: /hunt init, /hunt demo, /hunt scan, /hunt apply 3, /hunt tailor 3, /hunt hide 3, /hunt open 3, /hunt next 1, /hunt reject 1, /hunt close 1.
Search (s) asks Claude to find up to 20 jobs that match your target, check that each posting still accepts applications, skip companies you already applied to, and save the result. Jobs found in the last three days get a •; jobs older than 30 days drop out.
Tailor (t) asks Claude to create a <company>/ folder modelled on your latest tailored copy, using only verified facts, and to list the requirements your resume does not cover. It does not log the application: press a once you have sent it.
Claude can also call the mod's tools directly whenever you mention your search:
| Tool | What it does |
|---|---|
mcp__job-hunt__save_leads | merges found jobs into hunt/leads.json by url |
mcp__job-hunt__log_application | logs an application; a repeat for the same company and role, or the same url, updates it |
mcp__job-hunt__set_stage | moves an application to screen, interview, offer, rejected or closed |
Set these in /config (each one is a row there) or under pluginConfigs in your settings:
| Option | Default | Used for |
|---|---|---|
target | roles that match the resume: abroad with visa sponsorship or relocation, or fully remote from my country | what the search looks for, in one line. It also sets the balance between remote and relocation (fully remote; relocation only with sponsorship, as a fallback) and any contract form you need |
exclude | empty | kinds of jobs the search skips, e.g. staff/lead roles, frontend, internships |
facts | CLAUDE.local.md | a file in your folder with verified facts beyond the resume; search and tailoring may use only these and the resume |
layout | one list | how JOBS is shown: one list by fit, or relocate first / remote first for two groups; row numbers follow what you see |
my-job-search/
├── resume.txt required: marks the folder; the panel reads your profile from it
├── resume.html, *.pdf optional formats of the base resume
├── resume-web.html resume-<name>.txt|html|md in the root is a variant called <name>
├── acme/ a tailored copy per company: resume.* and a PDF
├── CLAUDE.local.md optional verified facts beyond the resume (see `facts`)
└── hunt/
├── applications.json written by the mod and its tools
└── leads.json written by save_leads
The panel takes the first three non-empty lines of resume.txt as your name, title and contacts (the first contact, split on ·, • or |, is your location). It also reads the first three Key: a, b, c rows under a SKILLS heading and an English (C1) style level. A resume in another shape still works; the panel just shows less.
hunt/*.json, nothing else.resume.txt it adds only the /hunt command, with no tools and no panel.applications.json, so a new search does not bring them back.The panel did not open. It opens by itself on a terminal at least 144 columns wide. Below that, type /hunt. After you have opened it once, it docks by itself from 110 columns. On a narrower terminal it sits above the prompt.
Does it work in the desktop app? Yes: the panel draws in the terminal and in the desktop app's Code tab.
Does it apply for me? No. It finds, ranks and tailors; you read the posting and press send.
git clone https://github.com/vlle/job-hunt-cc.git && cd job-hunt-cc
claude plugin validate .
claude plugin test .
claude --plugin-dir . # hot reload; also lays the API types in .claude-plugin/types/
npx -p typescript tsc -p . # TypeScript 5.4+, after one interactive run above
hooks/register.tsx: hooks, tools, file I/O and the session state ($.state atoms)hooks/hunt.ts: pure logic (tracker, jobs, funnel, variants, prompts)hooks/panel.tsx: the panel, a client modulehooks/demo.ts: the made-up data for /hunt demotypes/index.d.ts: the state contracthooks/register.tsx 613 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, HookFailure, Register } from 'claude-code'
3
4import {
5 DEFAULT_LAYOUT,
6 EMPTY_FOLDER,
7 EMPTY_LEADS,
8 EMPTY_TRACKER,
9 STAGES,
10 STAGE_LABEL,
11 hideLead,
12 initPromptOf,
13 isDay,
14 isLayout,
15 isStage,
16 jsonTextOf,
17 leadsOf,
18 logApplication,
19 mergeLeads,
20 nextStageOf,
21 patternOf,
22 profileOf,
23 rowCommandOf,
24 rowRefsOf,
25 scanPromptOf,
26 setStage,
27 sortedApplicationsOf,
28 tailorPromptOf,
29 tailoredOf,
30 todayOf,
31 trackerOf,
32 variantsOf,
33 visibleLeadsOf,
34 type Busy,
35 type Change,
36 type Config,
37 type FileEntry,
38 type Folder,
39 type Layout,
40 type PanelProps,
41 type Pending,
42 type Tracker,
43} from './hunt'
44import { demoOf } from './demo'
45
46type Message = { act: string; id: string | null }
47type World = { config: Config; cwd: string; isWatching: boolean }
48type Shown = { folder: Folder; now: number; busy: Busy | null; isDemo: boolean; layout: Layout }
49
50const PANE_ID = 'job-hunt'
51const PANEL_KEY = 'job-hunt:panel'
52const PANE_COLUMNS = 44
53const POLL_MS = 3000
54const MINUTE_MS = 60_000
55const BUSY_LIMIT_MS = 45 * 60_000
56const ROOT_MARKER = 'resume.txt'
57const TRACKER_PATH = 'hunt/applications.json'
58const LEADS_PATH = 'hunt/leads.json'
59const SKIPPED_DIRS = new Set(['hunt', 'node_modules'])
60const DEFAULT_TARGET =
61 'roles that match the resume: abroad with visa sponsorship or relocation, or fully remote from my country'
62const DEFAULT_FACTS = 'CLAUDE.local.md'
63const URL_OPENERS = [['xdg-open'], ['open'], ['rundll32', 'url.dll,FileProtocolHandler']]
64const ACTS = new Set(['init', 'scan', 'anon', 'tailor', 'apply', 'hide', 'open', 'next', 'reject', 'close'])
65const DEMO_ARGS = new Set(['demo', 'anon'])
66const INIT_LABEL = 'Claude is setting up this folder'
67const SCAN_LABEL = 'Claude is searching for jobs'
68const SCAN_DONE = 'search finished, new jobs are in the panel'
69const DEMO_REFUSAL = 'demo data on: actions are off, /hunt demo brings real data back'
70const NO_ROOT = 'no resume.txt here yet: /hunt init sets this folder up, /hunt demo shows made-up data'
71
72const FOLDER = atom({ plugin: 'job-hunt', key: 'folder' } as const, EMPTY_FOLDER)
73const NOW = atom({ plugin: 'job-hunt', key: 'now' } as const, 0)
74const PENDING = atom({ plugin: 'job-hunt', key: 'pending' } as const, null)
75const IS_DEMO = atom({ plugin: 'job-hunt', key: 'isDemo' } as const, false)
76
77const TOOLS = [
78 {
79 name: 'save_leads',
80 isDeferred: false,
81 description:
82 'Saves found jobs to hunt/leads.json and shows them in the job-hunt panel. Merges with earlier scans by url: a repeated job is updated, jobs older than 30 days drop out. Only real open jobs with a direct link.',
83 inputSchema: {
84 type: 'object',
85 properties: {
86 query: { type: 'string', description: 'what was searched and where, one line' },
87 leads: {
88 type: 'array',
89 items: {
90 type: 'object',
91 properties: {
92 company: { type: 'string' },
93 title: { type: 'string', description: 'job title as in the posting' },
94 url: { type: 'string', description: 'direct link to the posting (http/https)' },
95 location: { type: 'string', description: 'city and work format, as in the posting' },
96 country: { type: 'string', description: 'office country code: UK, DE, NL…; EU or WW for remote' },
97 sponsor: { type: 'boolean', description: 'the posting explicitly mentions a visa, sponsorship or relocation' },
98 remote: {
99 type: 'boolean',
100 description:
101 'fully remote and workable from the candidate country (the resume location) under any contract form: worldwide, a region that includes it, or as a contractor/B2B; remote limited to a country or region the candidate does not live in, or to holders of a work permit they lack, is false',
102 },
103 salary: { type: 'string', description: 'salary range, if stated' },
104 posted: { type: 'string', description: 'publication date YYYY-MM-DD' },
105 fit: { type: 'number', minimum: 0, maximum: 100, description: 'match with the resume' },
106 why: { type: 'string', description: 'why this fit and what is missing, in English, up to 90 chars' },
107 },
108 required: ['company', 'title', 'url', 'fit'],
109 },
110 },
111 },
112 required: ['leads'],
113 },
114 },
115 {
116 name: 'log_application',
117 isDeferred: false,
118 description:
119 'Logs a sent application in hunt/applications.json (stage "applied"). A repeated call for the same company and role or the same url updates the entry instead of duplicating it.',
120 inputSchema: {
121 type: 'object',
122 properties: {
123 company: { type: 'string' },
124 role: { type: 'string' },
125 url: { type: 'string' },
126 resume: { type: 'string', description: 'which resume variant was sent: resume.txt or a folder such as acme/' },
127 on: { type: 'string', description: 'application date YYYY-MM-DD, today by default' },
128 note: { type: 'string', description: 'short note in English' },
129 },
130 required: ['company', 'role'],
131 },
132 },
133 {
134 name: 'set_stage',
135 isDeferred: false,
136 description:
137 'Moves an application through the funnel: applied → screen → interview → offer, or rejected / closed. ref is the application id or the company name.',
138 inputSchema: {
139 type: 'object',
140 properties: {
141 ref: { type: 'string' },
142 stage: { type: 'string', enum: [...STAGES] },
143 on: { type: 'string', description: 'date YYYY-MM-DD, today by default' },
144 note: { type: 'string', description: 'short note in English' },
145 },
146 required: ['ref', 'stage'],
147 },
148 },
149]
150
151export const register: Register = (on, options) => {
152 const world: World = {
153 config: {
154 target: optionOf(options.target, DEFAULT_TARGET),
155 exclude: optionOf(options.exclude, ''),
156 facts: optionOf(options.facts, DEFAULT_FACTS),
157 layout: isLayout(options.layout) ? options.layout : DEFAULT_LAYOUT,
158 },
159 cwd: '',
160 isWatching: false,
161 }
162
163 on('session.start', async ($, e, next) => {
164 world.cwd = e.cwd
165 await $.command
166 .register({
167 name: 'hunt',
168 description: 'job-hunt panel: applications, jobs that fit your resume, tailored resumes; init sets a folder up, demo shows made-up data',
169 argumentHint: '[init | demo | scan | apply|tailor|hide|open|next|reject|close <row>]',
170 })
171 .catch(() => undefined)
172
173 const root = await rootOf($, e.cwd)
174 await update($, FOLDER, current => (current.root === root ? current : { ...EMPTY_FOLDER, root }))
175 if (root) {
176 await registerTools($)
177 }
178
179 const poll = async () => {
180 try {
181 await refresh($, world)
182 } catch (error) {
183 $.ui.log(`job-hunt: polling ${world.cwd} failed: ${String(error)}`, { to: 'debug' })
184 }
185 $.clock.after(POLL_MS, () => void poll())
186 }
187 await poll()
188 if (root) {
189 void showPane($, false)
190 }
191
192 return next(e)
193 })
194
195 on('command.run', { command: 'hunt' }, async ($, e) => {
196 const args = e.args.trim()
197 if (DEMO_ARGS.has(args)) {
198 await toggleDemo($)
199 await showPane($, true)
200
201 return {}
202 }
203 if (args === '') {
204 world.isWatching = true
205 await showPane($, true)
206
207 return {}
208 }
209 if (args === 'init') {
210 const { root } = await read($, FOLDER)
211 if (root) {
212 return { text: `job-hunt: this folder is set up already, see ${root}/${ROOT_MARKER}` }
213 }
214 world.isWatching = true
215 $.clock.after(0, () => void dispatch($, world, { act: 'init', id: null }))
216 await showPane($, true)
217
218 return {}
219 }
220 if (await read($, IS_DEMO)) {
221 return { text: `job-hunt: ${DEMO_REFUSAL}` }
222 }
223 const folder = await read($, FOLDER)
224 if (!folder.root) {
225 return { text: `job-hunt: ${NO_ROOT}` }
226 }
227 if (args === 'scan') {
228 $.clock.after(0, () => void dispatch($, world, { act: 'scan', id: null }))
229
230 return {}
231 }
232 const visible = visibleLeadsOf(folder.leads, folder.tracker, world.config.layout)
233 const refs = rowRefsOf(sortedApplicationsOf(folder.tracker.applications), visible)
234 const command = rowCommandOf(args, refs)
235 if ('error' in command) {
236 return { text: `job-hunt: ${command.error}` }
237 }
238 $.clock.after(0, () => void dispatch($, world, command))
239
240 return {}
241 })
242
243 on('turn.start', async ($, e, next) => {
244 const entry = await read($, PENDING)
245 if (entry && entry.turnId === null && e.agentId === undefined && e.text.includes(entry.text)) {
246 await update($, PENDING, current => (current && e.text.includes(current.text) ? { ...current, turnId: e.turnId } : current))
247 }
248
249 return next(e)
250 })
251
252 on('turn.complete', async ($, e, next) => {
253 const result = await next(e)
254 const entry = await read($, PENDING)
255 if (entry && e.agentId === undefined && entry.turnId === e.turnId) {
256 await settle($, entry, entry.busy.kind === 'tailor')
257 await refresh($, world)
258 }
259
260 return result
261 })
262
263 on('ui.message', async ($, e, next) => {
264 if (e.element !== PANEL_KEY) {
265 return next(e)
266 }
267 const message = messageOf(e.data)
268 if (message?.act === 'anon') {
269 await toggleDemo($)
270 } else if (message) {
271 await dispatch($, world, message)
272 }
273
274 return {}
275 })
276
277 on('tool.call', { tool: 'mcp__job-hunt__save_leads' }, async ($, e) => {
278 const { root } = await read($, FOLDER)
279 if (!root) {
280 return { deny: 'job-hunt: resume.txt not found, nowhere to save' }
281 }
282 const current = leadsOf(await $.fs.read(`${root}/${LEADS_PATH}`).catch(() => ''))
283 if (!current) {
284 return { deny: `${LEADS_PATH} is not valid JSON — fix the file, the mod will not overwrite it` }
285 }
286 const incoming = Array.isArray(e.leads) ? e.leads : []
287 const query = typeof e.query === 'string' && e.query.trim() !== '' ? e.query.trim() : null
288 const merged = mergeLeads(current, incoming, query, await $.clock.now())
289 await $.fs.write(`${root}/${LEADS_PATH}`, jsonTextOf(merged.leads))
290 await refresh($, world)
291 const folder = await read($, FOLDER)
292 const shown = visibleLeadsOf(folder.leads, folder.tracker, world.config.layout).length
293
294 return {
295 result: `saved to ${LEADS_PATH}: +${merged.added} new, ${merged.updated} updated, ${merged.skipped} skipped without company/title/url; ${shown} in the panel`,
296 }
297 }).catch(($, e, next) => ({ deny: failureOf('save_leads', next.error) }))
298
299 on('tool.call', { tool: 'mcp__job-hunt__log_application' }, async ($, e) => {
300 const company = typeof e.company === 'string' ? e.company.trim() : ''
301 const role = typeof e.role === 'string' ? e.role.trim() : ''
302 const { root } = await read($, FOLDER)
303 if (!root || !company || !role) {
304 return { deny: root ? 'company and role are required' : 'job-hunt: resume.txt not found' }
305 }
306 const input = {
307 company,
308 role,
309 url: typeof e.url === 'string' ? e.url : null,
310 resume: typeof e.resume === 'string' ? e.resume : null,
311 on: typeof e.on === 'string' ? e.on : null,
312 note: typeof e.note === 'string' ? e.note : null,
313 }
314 const outcome = await mutate($, world, (fresh, today) => logApplication(fresh, input, today))
315
316 return typeof outcome === 'string'
317 ? { deny: outcome }
318 : { result: `logged application ${outcome.id} (${STAGE_LABEL.applied}) in ${TRACKER_PATH}` }
319 }).catch(($, e, next) => ({ deny: failureOf('log_application', next.error) }))
320
321 on('tool.call', { tool: 'mcp__job-hunt__set_stage' }, async ($, e) => {
322 const ref = typeof e.ref === 'string' ? e.ref.trim() : ''
323 const stage = e.stage
324 const { root } = await read($, FOLDER)
325 if (!root || !ref || !isStage(stage)) {
326 return { deny: root ? `ref and stage (one of ${STAGES.join(', ')}) are required` : 'job-hunt: resume.txt not found' }
327 }
328 const note = typeof e.note === 'string' ? e.note : null
329 const day = isDay(e.on) ? e.on : null
330 const outcome = await mutate($, world, (fresh, today) => setStage(fresh, ref, stage, day ?? today, note))
331
332 return typeof outcome === 'string' ? { deny: outcome } : { result: `${outcome.id} → ${STAGE_LABEL[stage]}` }
333 }).catch(($, e, next) => ({ deny: failureOf('set_stage', next.error) }))
334
335 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e, next) => {
336 if (e.surface !== 'terminal' && e.surface !== 'desktop') {
337 return next(e)
338 }
339
340 const { Client } = $.ui.resolve(e)
341 const [folder, now, pending, isDemo] = await Promise.all([read($, FOLDER), read($, NOW), read($, PENDING), read($, IS_DEMO)])
342 const shown: Shown = { folder, now, busy: pending?.busy ?? null, isDemo, layout: world.config.layout }
343
344 return (
345 <Client
346 key={PANEL_KEY}
347 module="./panel.tsx"
348 props={panelPropsOf(shown, e.props.bodyColumns, e.props.placement === 'dock')}
349 />
350 )
351 })
352}
353
354function panelPropsOf(shown: Shown, columns: number, isDocked: boolean): PanelProps {
355 const { folder, now, layout } = shown
356 if (shown.isDemo) {
357 const demo = demoOf(now)
358 const leads = visibleLeadsOf({ ...EMPTY_LEADS, leads: demo.leads }, EMPTY_TRACKER, layout)
359
360 return { now, hasRoot: true, busy: null, columns, isDocked, layout, ...demo, leads }
361 }
362
363 return {
364 now,
365 hasRoot: folder.root !== '',
366 profile: folder.profile,
367 variants: variantsOf(folder.files, folder.tracker.applications),
368 applications: sortedApplicationsOf(folder.tracker.applications),
369 leads: visibleLeadsOf(folder.leads, folder.tracker, layout),
370 scanned_ms: folder.leads.scanned_ms,
371 layout,
372 busy: shown.busy,
373 columns,
374 isDocked,
375 }
376}
377
378async function rootOf($: EngineInterface, cwd: string): Promise<string> {
379 const candidates = [cwd, cwd.replace(/\/[^/]+$/, ''), cwd.replace(/(\/[^/]+){2}$/, '')]
380 for (const candidate of candidates) {
381 if (candidate && (await $.fs.exists(`${candidate}/${ROOT_MARKER}`).catch(() => false))) {
382 return candidate
383 }
384 }
385
386 return ''
387}
388
389async function registerTools($: EngineInterface): Promise<void> {
390 await Promise.all(TOOLS.map(tool => $.tool.register(tool).catch(() => undefined)))
391}
392
393async function showPane($: EngineInterface, isAsked: boolean): Promise<void> {
394 await $.ui
395 .open({ id: PANE_ID, title: 'job-hunt', columns: PANE_COLUMNS, ...(isAsked ? { focus: true } : {}) })
396 .catch(() => undefined)
397}
398
399async function readFiles($: EngineInterface, root: string): Promise<FileEntry[]> {
400 const entries = await $.fs.list(root).catch(() => [])
401 const dirs = entries.filter(entry => entry.kind === 'dir' && !entry.name.startsWith('.') && !SKIPPED_DIRS.has(entry.name))
402 const nested = await Promise.all(
403 dirs.map(async dir => (await $.fs.list(`${root}/${dir.name}`).catch(() => [])).map(entry => ({ ...entry, dir: dir.name }))),
404 )
405 const candidates = [...entries.map(entry => ({ ...entry, dir: '' })), ...nested.flat()].filter(
406 entry => entry.kind === 'file' && /^resume|\.pdf$/i.test(entry.name),
407 )
408
409 return Promise.all(
410 candidates.map(async entry => {
411 const relative = entry.dir ? `${entry.dir}/${entry.name}` : entry.name
412 const stat = await $.fs.stat(`${root}/${relative}`).catch(() => undefined)
413
414 return { dir: entry.dir, name: entry.name, mtime_ms: stat?.mtimeMs ?? 0 }
415 }),
416 )
417}
418
419async function refresh($: EngineInterface, world: World): Promise<void> {
420 const now = await $.clock.now()
421 if (Math.floor((await read($, NOW)) / MINUTE_MS) !== Math.floor(now / MINUTE_MS)) {
422 await update($, NOW, () => now)
423 }
424
425 const current = await read($, FOLDER)
426 const root = current.root || (world.isWatching ? await rootOf($, world.cwd) : '')
427 if (!root) {
428 return
429 }
430 if (!current.root) {
431 await registerTools($)
432 }
433 const [trackerText, leadsText, resumeText, files] = await Promise.all([
434 $.fs.read(`${root}/${TRACKER_PATH}`).catch(() => ''),
435 $.fs.read(`${root}/${LEADS_PATH}`).catch(() => ''),
436 $.fs.read(`${root}/${ROOT_MARKER}`).catch(() => ''),
437 readFiles($, root),
438 ])
439 const folder: Folder = {
440 root,
441 profile: profileOf(resumeText),
442 files,
443 tracker: trackerOf(trackerText) ?? current.tracker,
444 leads: leadsOf(leadsText) ?? current.leads,
445 }
446 if (JSON.stringify(folder) !== JSON.stringify(current)) {
447 await update($, FOLDER, () => folder)
448 }
449
450 const entry = await read($, PENDING)
451 if (entry?.busy.kind === 'scan' && (folder.leads.scanned_ms ?? 0) > entry.busy.since_ms) {
452 await settle($, entry, true)
453 } else if (entry && now - entry.busy.since_ms > BUSY_LIMIT_MS) {
454 await settle($, entry, false)
455 }
456}
457
458async function mutate(
459 $: EngineInterface,
460 world: World,
461 change: (fresh: Tracker, today: string) => Change | Tracker | { error: string },
462): Promise<string | { id: string | null }> {
463 const { root } = await read($, FOLDER)
464 const path = `${root}/${TRACKER_PATH}`
465 const fresh = trackerOf(await $.fs.read(path).catch(() => ''))
466 if (!fresh) {
467 return `${TRACKER_PATH} is not valid JSON — fix it by hand, the mod will not overwrite it`
468 }
469 const outcome = change(fresh, todayOf(await $.clock.now()))
470 if ('error' in outcome) {
471 return outcome.error
472 }
473 const tracker = 'tracker' in outcome ? outcome.tracker : outcome
474 await $.fs.write(path, jsonTextOf(tracker))
475 await update($, FOLDER, current => ({ ...current, tracker }))
476 await refresh($, world)
477
478 return { id: 'application' in outcome ? outcome.application.id : null }
479}
480
481async function startBusy($: EngineInterface, busy: Omit<Busy, 'since_ms'>, text: string, done: string | null): Promise<void> {
482 const running = await read($, PENDING)
483 if (running) {
484 $.ui.toast(`job-hunt: already running: ${running.busy.label}`)
485
486 return
487 }
488 const entry: Pending = { busy: { ...busy, since_ms: await $.clock.now() }, text, turnId: null, done }
489 await update($, PENDING, () => entry)
490 void $.prompt
491 .submit({ text })
492 .then(result => {
493 if (result.drop !== undefined) {
494 void settle($, entry, false)
495 }
496 })
497 .catch(() => void settle($, entry, false))
498}
499
500async function settle($: EngineInterface, entry: Pending, isDone: boolean): Promise<void> {
501 const current = await read($, PENDING)
502 if (current?.text !== entry.text || current.busy.since_ms !== entry.busy.since_ms) {
503 return
504 }
505 await update($, PENDING, () => null)
506 if (isDone && entry.done) {
507 const sent = await $.ui.notify(entry.done).catch(() => undefined)
508 if (!sent?.isSent) {
509 $.ui.toast(`job-hunt: ${entry.done}`)
510 }
511 }
512}
513
514async function toggleDemo($: EngineInterface): Promise<void> {
515 const isOn = !(await read($, IS_DEMO))
516 await update($, IS_DEMO, () => isOn)
517 $.ui.toast(isOn ? 'job-hunt: demo data on, real data hidden' : 'job-hunt: real data back')
518}
519
520async function dispatch($: EngineInterface, world: World, message: Message): Promise<void> {
521 const error = await perform($, world, message)
522 if (error) {
523 $.ui.toast(`job-hunt: ${error}`)
524 }
525}
526
527async function perform($: EngineInterface, world: World, message: Message): Promise<string | undefined> {
528 if (await read($, IS_DEMO)) {
529 return DEMO_REFUSAL
530 }
531 const folder = await read($, FOLDER)
532 if (message.act === 'init') {
533 world.isWatching = true
534 if (!folder.root) {
535 await startBusy($, { kind: 'init', label: INIT_LABEL }, initPromptOf(world.cwd), null)
536 }
537
538 return undefined
539 }
540 if (!folder.root) {
541 return NO_ROOT
542 }
543 const id = message.id ?? ''
544 const lead = folder.leads.leads.find(item => item.id === id || `lead:${item.id}` === id)
545 const app = folder.tracker.applications.find(item => item.id === id || `app:${item.id}` === id)
546 const variants = variantsOf(folder.files, folder.tracker.applications)
547 const failed = (outcome: string | { id: string | null }) => (typeof outcome === 'string' ? outcome : undefined)
548
549 switch (message.act) {
550 case 'scan':
551 await startBusy($, { kind: 'scan', label: SCAN_LABEL }, scanPromptOf(world.config, folder.tracker), SCAN_DONE)
552 return undefined
553 case 'tailor':
554 if (lead) {
555 const prompt = tailorPromptOf(lead, patternOf(variants), world.config.facts)
556 const done = `the resume for ${lead.company} is tailored`
557 await startBusy($, { kind: 'tailor', label: `Claude is tailoring the resume for ${lead.company}` }, prompt, done)
558 }
559 return undefined
560 case 'apply': {
561 if (!lead) {
562 return undefined
563 }
564 const input = { company: lead.company, role: lead.title, url: lead.url, resume: tailoredOf(lead, variants), note: lead.why }
565 return failed(await mutate($, world, (fresh, today) => logApplication(fresh, input, today)))
566 }
567 case 'hide':
568 return lead ? failed(await mutate($, world, fresh => hideLead(fresh, lead.id))) : undefined
569 case 'next': {
570 const stage = app ? nextStageOf(app.stage) : undefined
571 return app && stage ? failed(await mutate($, world, (fresh, today) => setStage(fresh, app.id, stage, today))) : undefined
572 }
573 case 'reject':
574 return app ? failed(await mutate($, world, (fresh, today) => setStage(fresh, app.id, 'rejected', today))) : undefined
575 case 'close':
576 return app ? failed(await mutate($, world, (fresh, today) => setStage(fresh, app.id, 'closed', today))) : undefined
577 case 'open': {
578 const url = id.startsWith('app:') ? app?.url : lead?.url
579 return url ? openUrl($, url) : undefined
580 }
581 default:
582 return undefined
583 }
584}
585
586async function openUrl($: EngineInterface, url: string): Promise<string | undefined> {
587 for (const opener of URL_OPENERS) {
588 const result = await $.process.run([...opener, url], { timeoutMs: 10_000 }).catch(() => undefined)
589 if (result?.exitCode === 0) {
590 return undefined
591 }
592 }
593
594 return `could not open ${url}: no xdg-open, open or rundll32 worked`
595}
596
597function failureOf(tool: string, error: HookFailure): string {
598 return `job-hunt: ${tool} failed: ${error.message ?? error.kind}`
599}
600
601function optionOf(value: unknown, fallback: string): string {
602 return typeof value === 'string' && value.trim() !== '' ? value.trim() : fallback
603}
604
605function messageOf(data: unknown): Message | undefined {
606 if (typeof data !== 'object' || data === null) {
607 return undefined
608 }
609 const { act, id } = data as { act?: unknown; id?: unknown }
610
611 return typeof act === 'string' && ACTS.has(act) ? { act, id: typeof id === 'string' ? id : null } : undefined
612}
613hooks/hunt.ts 535 lines1import type { Application, Busy, FileEntry, Folder, Lead, Leads, Profile, Stage, Tracker } from '../types'
2
3export type { Application, Busy, FileEntry, Folder, Lead, Leads, Pending, Profile, Stage, Step, Tracker } from '../types'
4
5export type Variant = { name: string; path: string; formats: string[]; updated_ms: number; sent: number }
6
7export type Layout = 'one list' | 'relocate first' | 'remote first'
8
9export type Config = { target: string; exclude: string; facts: string; layout: Layout }
10
11export type PanelProps = {
12 now: number
13 hasRoot: boolean
14 profile: Profile | null
15 variants: Variant[]
16 applications: Application[]
17 leads: Lead[]
18 scanned_ms: number | null
19 layout: Layout
20 busy: Busy | null
21 columns: number
22 isDocked: boolean
23}
24
25export type ApplicationInput = {
26 company: string
27 role: string
28 url?: string | null
29 resume?: string | null
30 on?: string | null
31 note?: string | null
32}
33
34export type Change = { tracker: Tracker; application: Application }
35
36export const DAY_MS = 86_400_000
37export const SILENCE_DAYS = 21
38export const FRESH_MS = 3 * DAY_MS
39export const STAGES: readonly Stage[] = ['applied', 'screen', 'interview', 'offer', 'rejected', 'closed']
40export const FUNNEL: readonly Stage[] = ['applied', 'screen', 'interview', 'offer']
41export const STAGE_LABEL: Record<Stage, string> = {
42 applied: 'applied',
43 screen: 'screen',
44 interview: 'interview',
45 offer: 'offer',
46 rejected: 'rejected',
47 closed: 'closed',
48}
49export const EMPTY_TRACKER: Tracker = { v: 1, applications: [], hidden: [] }
50export const EMPTY_LEADS: Leads = { v: 1, scanned_ms: null, query: null, leads: [] }
51export const EMPTY_FOLDER: Folder = { root: '', profile: null, files: [], tracker: EMPTY_TRACKER, leads: EMPTY_LEADS }
52export const LAYOUTS: readonly Layout[] = ['one list', 'relocate first', 'remote first']
53export const DEFAULT_LAYOUT: Layout = 'one list'
54export const USAGE = 'usage: /hunt [init | demo | scan | apply|tailor|hide|open|next|reject|close <row>]'
55
56const LEAD_TTL_MS = 30 * DAY_MS
57const SCAN_LIMIT = 20
58const REMOTE_REGIONS = new Set(['EU', 'WW'])
59const REMOTE_PLACE = /remote|home[\s-]?based/i
60const ROW_KINDS: Record<string, readonly string[]> = {
61 apply: ['lead'],
62 tailor: ['lead'],
63 hide: ['lead'],
64 open: ['app', 'lead'],
65 next: ['app'],
66 reject: ['app'],
67 close: ['app'],
68}
69const RESUME_FILE = /^resume(?:-([\w-]+))?\.(txt|html|md)$/i
70const PDF_FILE = /\.pdf$/i
71const FORMAT_ORDER = ['txt', 'html', 'md', 'pdf']
72const SKILL_ROWS = 3
73const SKILLS_HEAD = /^(?:technical |core |key )?skills:?$/i
74const SECTION_HEAD = /^[A-Z][A-Z &/-]{2,}$/
75const CONTACT_SEPARATOR = /\s+[·•|]\s+/
76const SHORT_SKILL: Record<string, string> = {
77 PostgreSQL: 'PG',
78 'Apache Kafka': 'Kafka',
79 ClickHouse: 'CH',
80 Kubernetes: 'k8s',
81}
82
83export function isStage(value: unknown): value is Stage {
84 return typeof value === 'string' && (STAGES as readonly string[]).includes(value)
85}
86
87export function isLayout(value: unknown): value is Layout {
88 return typeof value === 'string' && (LAYOUTS as readonly string[]).includes(value)
89}
90
91export function isDay(value: unknown): value is string {
92 return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)
93}
94
95export function idOf(text: string): string {
96 return text
97 .toLowerCase()
98 .replace(/[^\p{L}\p{N}]+/gu, '-')
99 .replace(/^-+|-+$/g, '')
100}
101
102export function urlKeyOf(url: string): string {
103 return url
104 .trim()
105 .replace(/[?#].*$/, '')
106 .replace(/\/+$/, '')
107 .replace(/^https?:\/\/(www\.)?/i, '')
108 .toLowerCase()
109}
110
111export function todayOf(ms: number): string {
112 return new Date(ms).toISOString().slice(0, 10)
113}
114
115export function daysSinceOf(day: string, now: number): number {
116 const at = Date.parse(`${day}T00:00:00Z`)
117
118 return Number.isFinite(at) ? Math.max(0, Math.floor((now - at) / DAY_MS)) : 0
119}
120
121export function shortDayOf(day: string | null): string {
122 return isDay(day) ? `${day.slice(8, 10)}.${day.slice(5, 7)}` : '—'
123}
124
125export function trackerOf(text: string): Tracker | undefined {
126 if (text.trim() === '') {
127 return EMPTY_TRACKER
128 }
129 const raw = recordOf(jsonOf(text))
130 if (!raw) {
131 return undefined
132 }
133 const applications = arrayOf(raw.applications)
134 .map(applicationOf)
135 .filter((app): app is Application => app !== undefined)
136 const hidden = arrayOf(raw.hidden).filter((id): id is string => typeof id === 'string')
137
138 return { v: 1, applications, hidden }
139}
140
141export function leadsOf(text: string): Leads | undefined {
142 if (text.trim() === '') {
143 return EMPTY_LEADS
144 }
145 const raw = recordOf(jsonOf(text))
146 if (!raw) {
147 return undefined
148 }
149 const leads = arrayOf(raw.leads)
150 .map(value => leadOf(value, 0))
151 .filter((lead): lead is Lead => lead !== undefined)
152
153 return {
154 v: 1,
155 scanned_ms: typeof raw.scanned_ms === 'number' ? raw.scanned_ms : null,
156 query: textOf(raw.query),
157 leads,
158 }
159}
160
161export function leadOf(value: unknown, now: number): Lead | undefined {
162 const raw = recordOf(value)
163 const company = textOf(raw?.company)
164 const title = textOf(raw?.title)
165 const url = textOf(raw?.url)
166 if (!raw || !company || !title || !url || !/^https?:\/\//i.test(url)) {
167 return undefined
168 }
169 const fit = typeof raw.fit === 'number' && Number.isFinite(raw.fit) ? Math.round(Math.min(100, Math.max(0, raw.fit))) : 0
170 const location = textOf(raw.location) ?? ''
171 const country = (textOf(raw.country) ?? '').toUpperCase().slice(0, 3)
172
173 return {
174 id: urlKeyOf(url),
175 company,
176 title,
177 url,
178 location,
179 country,
180 sponsor: raw.sponsor === true,
181 remote: typeof raw.remote === 'boolean' ? raw.remote : isRemoteAbroad(country, location),
182 salary: textOf(raw.salary),
183 posted: isDay(raw.posted) ? raw.posted : null,
184 fit,
185 why: textOf(raw.why),
186 found_ms: typeof raw.found_ms === 'number' ? raw.found_ms : now,
187 }
188}
189
190export function jsonTextOf(value: Tracker | Leads): string {
191 return `${JSON.stringify(value, null, 2)}\n`
192}
193
194export function isActive(app: Application): boolean {
195 return FUNNEL.includes(app.stage)
196}
197
198export function lastDayOf(app: Application): string | null {
199 return app.history.at(-1)?.on ?? null
200}
201
202export function isSilent(app: Application, now: number): boolean {
203 const last = lastDayOf(app)
204
205 return isActive(app) && app.stage !== 'offer' && last !== null && daysSinceOf(last, now) >= SILENCE_DAYS
206}
207
208export function nextStageOf(stage: Stage): Stage | undefined {
209 const index = FUNNEL.indexOf(stage)
210
211 return index >= 0 && index < FUNNEL.length - 1 ? FUNNEL[index + 1] : undefined
212}
213
214export function funnelOf(apps: readonly Application[]): { stage: Stage; count: number }[] {
215 const isReached = (app: Application, stage: Stage) => {
216 const rank = FUNNEL.indexOf(stage)
217
218 return [app.stage, ...app.history.map(step => step.stage)].some(seen => FUNNEL.indexOf(seen) >= rank)
219 }
220
221 return FUNNEL.map(stage => ({ stage, count: apps.filter(app => isReached(app, stage)).length }))
222}
223
224export function sortedApplicationsOf(apps: readonly Application[]): Application[] {
225 const rank = (app: Application) => (isActive(app) ? FUNNEL.indexOf(app.stage) : -1)
226
227 return [...apps].sort((a, b) => rank(b) - rank(a) || (lastDayOf(b) ?? '').localeCompare(lastDayOf(a) ?? ''))
228}
229
230export function rowRefsOf(sorted: readonly Application[], leads: readonly Lead[]): string[] {
231 return [...sorted.filter(isActive).map(app => `app:${app.id}`), ...leads.map(lead => `lead:${lead.id}`)]
232}
233
234export function rowCommandOf(args: string, refs: readonly string[]): { act: string; id: string } | { error: string } {
235 const [act = '', row = '', ...rest] = args.split(/\s+/)
236 const kinds = ROW_KINDS[act]
237 if (!kinds || rest.length > 0 || !/^\d+$/.test(row)) {
238 return { error: USAGE }
239 }
240 const id = refs[Number(row) - 1]
241 if (!id) {
242 return { error: `no row ${row}` }
243 }
244 const kind = id.slice(0, id.indexOf(':'))
245 if (!kinds.includes(kind)) {
246 return { error: `row ${row} is ${kind === 'app' ? 'an application' : 'a job'}, ${act} works on ${kind === 'app' ? 'jobs' : 'applications'}` }
247 }
248
249 return { act, id }
250}
251
252export function logApplication(tracker: Tracker, input: ApplicationInput, today: string): Change {
253 const url = textOf(input.url)
254 const key = url ? urlKeyOf(url) : null
255 const id = idOf(`${input.company} ${input.role}`)
256 const existing = tracker.applications.find(app => app.id === id || (key !== null && app.url !== null && urlKeyOf(app.url) === key))
257 const application: Application = existing
258 ? {
259 ...existing,
260 url: existing.url ?? url,
261 resume: textOf(input.resume) ?? existing.resume,
262 note: textOf(input.note) ?? existing.note,
263 }
264 : {
265 id,
266 company: input.company.trim(),
267 role: input.role.trim(),
268 url,
269 resume: textOf(input.resume),
270 stage: 'applied',
271 history: [{ stage: 'applied', on: isDay(input.on) ? input.on : today }],
272 note: textOf(input.note),
273 }
274 const applications = existing
275 ? tracker.applications.map(app => (app === existing ? application : app))
276 : [...tracker.applications, application]
277
278 return { tracker: { ...tracker, applications }, application }
279}
280
281export function setStage(
282 tracker: Tracker,
283 ref: string,
284 stage: Stage,
285 on: string,
286 note?: string | null,
287): Change | { error: string } {
288 const matches = applicationsOf(tracker, ref)
289 const [found] = matches
290 if (!found) {
291 return { error: `no application "${ref}"` }
292 }
293 if (matches.length > 1) {
294 return { error: `"${ref}" is ambiguous: ${matches.map(app => app.id).join(', ')}` }
295 }
296 const application: Application = {
297 ...found,
298 stage,
299 history: found.stage === stage ? found.history : [...found.history, { stage, on }],
300 note: textOf(note) ?? found.note,
301 }
302
303 return {
304 tracker: { ...tracker, applications: tracker.applications.map(app => (app === found ? application : app)) },
305 application,
306 }
307}
308
309export function hideLead(tracker: Tracker, id: string): Tracker {
310 return tracker.hidden.includes(id) ? tracker : { ...tracker, hidden: [...tracker.hidden, id] }
311}
312
313export function visibleLeadsOf(leads: Leads, tracker: Tracker, layout: Layout): Lead[] {
314 const hidden = new Set(tracker.hidden)
315 const applied = new Set(tracker.applications.flatMap(app => (app.url ? [app.id, urlKeyOf(app.url)] : [app.id])))
316 const groupOf = (lead: Lead) =>
317 layout === 'relocate first' ? Number(lead.remote) : layout === 'remote first' ? Number(!lead.remote) : 0
318
319 return leads.leads
320 .filter(lead => !hidden.has(lead.id) && !applied.has(lead.id) && !applied.has(idOf(`${lead.company} ${lead.title}`)))
321 .sort((a, b) => groupOf(a) - groupOf(b) || b.fit - a.fit || (b.posted ?? '').localeCompare(a.posted ?? ''))
322}
323
324export function mergeLeads(
325 current: Leads,
326 incoming: readonly unknown[],
327 query: string | null,
328 now: number,
329): { leads: Leads; added: number; updated: number; skipped: number } {
330 const known = new Map(current.leads.map(lead => [lead.id, lead]))
331 const fresh = new Map<string, Lead>()
332 let skipped = 0
333
334 for (const value of incoming) {
335 const lead = leadOf(value, now)
336 if (!lead) {
337 skipped += 1
338 continue
339 }
340 fresh.set(lead.id, { ...lead, found_ms: known.get(lead.id)?.found_ms ?? fresh.get(lead.id)?.found_ms ?? now })
341 }
342
343 const kept = current.leads.filter(lead => !fresh.has(lead.id) && now - lead.found_ms < LEAD_TTL_MS)
344 const added = [...fresh.keys()].filter(id => !known.has(id)).length
345
346 return {
347 leads: { v: 1, scanned_ms: now, query: query ?? current.query, leads: [...fresh.values(), ...kept] },
348 added,
349 updated: fresh.size - added,
350 skipped,
351 }
352}
353
354export function profileOf(text: string): Profile | null {
355 const lines = text.split('\n').map(line => line.trim())
356 const [name, title, contacts] = lines.filter(Boolean)
357 if (!name || !title) {
358 return null
359 }
360 const start = lines.findIndex(line => SKILLS_HEAD.test(line))
361 const end = lines.findIndex((line, index) => index > start && SECTION_HEAD.test(line))
362 const block = start < 0 ? [] : lines.slice(start + 1, end > start ? end : undefined)
363 const skills = block
364 .filter(line => line.indexOf(':') > 0)
365 .slice(0, SKILL_ROWS)
366 .flatMap(line => line.slice(line.indexOf(':') + 1).split(','))
367 .map(item => item.replace(/\(.*?\)/g, '').trim())
368 .filter(item => /^[A-Z]/.test(item))
369 .map(item => SHORT_SKILL[item] ?? item)
370 const english = /English \(([A-C][12])\)/.exec(text)?.[1]
371
372 return {
373 name,
374 title,
375 place: contacts?.split(CONTACT_SEPARATOR)[0]?.trim() || null,
376 skills: [...new Set(skills)],
377 english: english ? `EN ${english}` : null,
378 }
379}
380
381export function variantsOf(files: readonly FileEntry[], apps: readonly Application[]): Variant[] {
382 const groups = new Map<string, { name: string; dir: string; files: FileEntry[] }>()
383
384 for (const file of files) {
385 const match = RESUME_FILE.exec(file.name)
386 if (!match || (file.dir !== '' && match[1])) {
387 continue
388 }
389 const name = file.dir === '' ? (match[1] ?? 'base') : file.dir
390 const group = groups.get(name) ?? { name, dir: file.dir, files: [] }
391 group.files.push(file)
392 groups.set(name, group)
393 }
394
395 for (const file of files) {
396 const group = file.dir === '' ? groups.get('base') : groups.get(file.dir)
397 if (PDF_FILE.test(file.name) && group && group.dir === file.dir) {
398 group.files.push(file)
399 }
400 }
401
402 const sentOf = (keys: string[]) =>
403 apps.filter(app => app.resume !== null && keys.includes(app.resume.replace(/\/+$/, '').toLowerCase())).length
404
405 return [...groups.values()]
406 .map(group => {
407 const formats = [...new Set(group.files.map(file => file.name.split('.').pop()?.toLowerCase() ?? ''))].sort(
408 (a, b) => FORMAT_ORDER.indexOf(a) - FORMAT_ORDER.indexOf(b),
409 )
410 const own = group.files.filter(file => RESUME_FILE.test(file.name))
411 const main = own.find(file => file.name.endsWith('.txt')) ?? own[0]
412 const path = group.dir === '' ? (main?.name ?? '') : `${group.dir}/`
413
414 return {
415 name: group.name,
416 path,
417 formats,
418 updated_ms: Math.max(0, ...group.files.map(file => file.mtime_ms)),
419 sent: sentOf([group.name.toLowerCase(), path.replace(/\/+$/, '').toLowerCase()]),
420 }
421 })
422 .sort((a, b) => (a.name === 'base' ? -1 : b.name === 'base' ? 1 : b.updated_ms - a.updated_ms))
423}
424
425export function patternOf(variants: readonly Variant[]): string | null {
426 return variants.filter(variant => variant.path.endsWith('/')).sort((a, b) => b.updated_ms - a.updated_ms)[0]?.path ?? null
427}
428
429export function tailoredOf(lead: Lead, variants: readonly Variant[]): string | null {
430 const folder = `${idOf(lead.company)}/`
431
432 return variants.find(variant => variant.path === folder || variant.path.startsWith(`${idOf(lead.company)}-`))?.path ?? null
433}
434
435export function scanPromptOf(config: Config, tracker: Tracker): string {
436 const applied = [...new Set(tracker.applications.map(app => app.company))]
437 const facts = config.facts ? `, verified facts about me are in ${config.facts}` : ''
438
439 return [
440 `Find jobs I should apply to: ${config.target}.`,
441 `The resume is resume.txt${facts}. Search the web with the tools you have and the public ATS APIs (Greenhouse, Lever, Ashby); open every posting and make sure it still accepts applications.`,
442 `Find up to ${SCAN_LIMIT} jobs that match the target, best matches first. Follow the target on how much remote work and relocation it wants; do not split the list evenly between them.`,
443 ...(config.exclude ? [`Skip ${config.exclude}.`] : []),
444 'sponsor=true only when the posting explicitly mentions a visa, sponsorship or relocation.',
445 'remote=true only when the posting lets me work from my country of residence (the resume location) without moving — worldwide, a region that includes my country, or as a contractor or B2B. The employer does not need an entity or payroll in my country: any contract form counts.',
446 'Remote limited to a country or region I do not live in, or to holders of a work permit I do not have (such as remote EU or remote UK), is remote=false.',
447 `Already applied to: ${applied.length > 0 ? applied.join(', ') : 'nowhere yet'} — do not suggest these companies.`,
448 'fit 0–100 is how well the job matches the resume (stack, level, domain); why says briefly why and what is missing, in English.',
449 `Save the result with a single mcp__job-hunt__save_leads call, at most ${SCAN_LIMIT} jobs; reply in chat with a short summary.`,
450 ].join('\n')
451}
452
453export function tailorPromptOf(lead: Lead, pattern: string | null, facts: string): string {
454 const folder = `${idOf(lead.company)}/`
455 const source = facts ? `the resume and ${facts}` : 'the resume'
456
457 return [
458 `Tailor the resume for ${lead.company} — ${lead.title}: ${lead.url}`,
459 pattern
460 ? `Read the whole posting and create the folder ${folder} following ${pattern}: resume.html, resume.txt, build.sh, a PDF of exactly one A4 page.`
461 : `Read the whole posting and create the folder ${folder}: resume.html, resume.txt and a PDF of exactly one A4 page.`,
462 `Only verified facts from ${source}: mirror the wording of the posting where it is true, never add experience that does not exist.`,
463 'At the end, list the requirements of the posting that the resume does not cover. Do not log the application — the panel button does that.',
464 ].join('\n')
465}
466
467export function initPromptOf(folder: string): string {
468 return [
469 `Set up this folder for the job-hunt panel: ${folder}`,
470 'Find my resume here: a PDF, DOCX, Markdown, HTML or text file, or a LinkedIn profile export. If there is none, ask me to paste my resume or give a path to it, and wait for my answer.',
471 'Write it to resume.txt as plain text: my name on the first line, my job title on the second, contacts on the third separated by " · " with my city first, then sections under UPPERCASE headings, one of them SKILLS with rows like "Languages: Go, SQL".',
472 'Use only facts from the source, never add anything. Do not touch the original file.',
473 'Then ask me in one line which jobs I am looking for (role, location or remote, visa needs) and tell me I can save it as the job-hunt "target" option in /config.',
474 'Finish by telling me to press s in the job-hunt panel to search for jobs.',
475 ].join('\n')
476}
477
478function applicationsOf(tracker: Tracker, ref: string): Application[] {
479 const key = idOf(ref)
480 const exact = tracker.applications.filter(app => app.id === ref || app.id === key)
481
482 return exact.length > 0
483 ? exact
484 : tracker.applications.filter(app => idOf(app.company) === key || app.id.startsWith(`${key}-`))
485}
486
487function applicationOf(value: unknown): Application | undefined {
488 const raw = recordOf(value)
489 const company = textOf(raw?.company)
490 if (!raw || !company) {
491 return undefined
492 }
493 const role = textOf(raw.role) ?? ''
494 const history = arrayOf(raw.history)
495 .map(recordOf)
496 .filter((step): step is Record<string, unknown> => step !== undefined)
497 .filter(step => isStage(step.stage) && isDay(step.on))
498 .map(step => ({ stage: step.stage as Stage, on: step.on as string }))
499
500 return {
501 id: textOf(raw.id) ?? idOf(`${company} ${role}`),
502 company,
503 role,
504 url: textOf(raw.url),
505 resume: textOf(raw.resume),
506 stage: isStage(raw.stage) ? raw.stage : (history.at(-1)?.stage ?? 'applied'),
507 history,
508 note: textOf(raw.note),
509 }
510}
511
512function isRemoteAbroad(country: string, location: string): boolean {
513 return REMOTE_REGIONS.has(country) && REMOTE_PLACE.test(location)
514}
515
516function recordOf(value: unknown): Record<string, unknown> | undefined {
517 return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined
518}
519
520function arrayOf(value: unknown): unknown[] {
521 return Array.isArray(value) ? value : []
522}
523
524function textOf(value: unknown): string | null {
525 return typeof value === 'string' && value.trim() !== '' ? value.trim() : null
526}
527
528function jsonOf(text: string): unknown {
529 try {
530 return JSON.parse(text)
531 } catch {
532 return undefined
533 }
534}
535hooks/demo.ts 178 lines1import {
2 DAY_MS,
3 idOf,
4 sortedApplicationsOf,
5 todayOf,
6 variantsOf,
7 type Application,
8 type FileEntry,
9 type Lead,
10 type PanelProps,
11 type Stage,
12} from './hunt'
13
14type Demo = Pick<PanelProps, 'profile' | 'variants' | 'applications' | 'leads' | 'scanned_ms'>
15type DemoApplication = Pick<Application, 'company' | 'role' | 'resume' | 'note'> & { steps: readonly (readonly [Stage, number])[] }
16type DemoLead = Omit<Lead, 'id' | 'url' | 'posted' | 'found_ms'> & { postedDaysAgo: number; foundDaysAgo: number }
17
18const HOUR_MS = 3_600_000
19const JOBS_URL = 'https://example.com/jobs'
20const PDF = 'Alex-Doe-Backend-Engineer.pdf'
21const BASE_DAYS_AGO = 2
22const FOLDERS: readonly (readonly [string, number])[] = [
23 ['hooli', 21],
24 ['globex', 12],
25 ['initech', 9],
26 ['pied-piper', 25],
27]
28
29const APPLICATIONS: readonly DemoApplication[] = [
30 {
31 company: 'Hooli',
32 role: 'Senior Backend Engineer, Payments',
33 resume: 'hooli/',
34 note: 'offer call on Friday',
35 steps: [['applied', 21], ['screen', 17], ['interview', 10], ['offer', 1]],
36 },
37 {
38 company: 'Globex',
39 role: 'Backend Engineer (Go)',
40 resume: 'globex/',
41 note: 'system design round booked',
42 steps: [['applied', 12], ['screen', 8], ['interview', 3]],
43 },
44 {
45 company: 'Initech',
46 role: 'Software Engineer, Core Services',
47 resume: 'initech/',
48 note: 'recruiter call went well',
49 steps: [['applied', 9], ['screen', 4]],
50 },
51 { company: 'Acme', role: 'Go Developer, Platform', resume: 'resume.txt', note: null, steps: [['applied', 2]] },
52 { company: 'Pied Piper', role: 'Backend Engineer, Storage', resume: 'pied-piper/', note: null, steps: [['applied', 25]] },
53 {
54 company: 'Umbrella',
55 role: 'Backend Engineer',
56 resume: 'resume.txt',
57 note: 'went with a more senior candidate',
58 steps: [['applied', 30], ['screen', 24], ['rejected', 18]],
59 },
60 { company: 'Soylent', role: 'Go Engineer', resume: 'resume.txt', note: 'role filled internally', steps: [['applied', 40], ['closed', 20]] },
61]
62
63const LEADS: readonly DemoLead[] = [
64 {
65 company: 'Vandelay',
66 title: 'Backend Engineer, Logistics',
67 location: 'London, hybrid',
68 country: 'UK',
69 sponsor: true,
70 remote: false,
71 salary: '£80–100k',
72 fit: 88,
73 why: 'Go, Kafka and Postgres match; AWS is a gap',
74 postedDaysAgo: 3,
75 foundDaysAgo: 1,
76 },
77 {
78 company: 'Cyberdyne',
79 title: 'Senior Go Engineer',
80 location: 'Amsterdam',
81 country: 'NL',
82 sponsor: true,
83 remote: false,
84 salary: '€75–90k',
85 fit: 79,
86 why: 'strong Go and k8s overlap; wants deeper gRPC',
87 postedDaysAgo: 5,
88 foundDaysAgo: 1,
89 },
90 {
91 company: 'Tyrell',
92 title: 'Platform Engineer (Go)',
93 location: 'Berlin, hybrid',
94 country: 'DE',
95 sponsor: true,
96 remote: false,
97 salary: null,
98 fit: 67,
99 why: 'platform focus; Terraform is missing',
100 postedDaysAgo: 8,
101 foundDaysAgo: 6,
102 },
103 {
104 company: 'Stark',
105 title: 'Go Backend Engineer',
106 location: 'Dublin',
107 country: 'IE',
108 sponsor: true,
109 remote: false,
110 salary: null,
111 fit: 46,
112 why: 'heavy AWS and DynamoDB; level may be too senior',
113 postedDaysAgo: 14,
114 foundDaysAgo: 9,
115 },
116 {
117 company: 'Aperture',
118 title: 'Go Engineer, Billing',
119 location: 'Remote, worldwide',
120 country: 'WW',
121 sponsor: false,
122 remote: true,
123 salary: '$90–110k',
124 fit: 71,
125 why: 'contractor through an EOR; Go and Postgres match',
126 postedDaysAgo: 4,
127 foundDaysAgo: 1,
128 },
129 {
130 company: 'Wonka',
131 title: 'Backend Engineer, Checkout',
132 location: 'Remote, EMEA',
133 country: 'EU',
134 sponsor: false,
135 remote: true,
136 salary: '€60–75k',
137 fit: 58,
138 why: 'hires across EMEA; stack is Go plus MongoDB',
139 postedDaysAgo: 11,
140 foundDaysAgo: 6,
141 },
142]
143
144export function demoOf(now: number): Demo {
145 const dayOf = (daysAgo: number) => todayOf(now - daysAgo * DAY_MS)
146 const applications = APPLICATIONS.map(({ steps, ...demo }): Application => {
147 const id = `demo-${idOf(demo.company)}`
148 const history = steps.map(([stage, daysAgo]) => ({ stage, on: dayOf(daysAgo) }))
149
150 return { ...demo, id, url: `${JOBS_URL}/${id}`, stage: history.at(-1)?.stage ?? 'applied', history }
151 })
152 const leads = LEADS.map(({ postedDaysAgo, foundDaysAgo, ...demo }): Lead => {
153 const url = `${JOBS_URL}/${idOf(`${demo.company} ${demo.title}`)}`
154
155 return { ...demo, id: url.replace(/^https:\/\//, ''), url, posted: dayOf(postedDaysAgo), found_ms: now - foundDaysAgo * DAY_MS }
156 })
157 const fileOf = (dir: string, name: string, daysAgo: number): FileEntry => ({ dir, name, mtime_ms: now - daysAgo * DAY_MS })
158 const files = [
159 ...['resume.txt', 'resume.html', PDF].map(name => fileOf('', name, BASE_DAYS_AGO)),
160 fileOf('', 'resume-web.html', 15),
161 ...FOLDERS.flatMap(([dir, daysAgo]) => ['resume.txt', 'resume.html', PDF].map(name => fileOf(dir, name, daysAgo))),
162 ]
163
164 return {
165 profile: {
166 name: 'Alex Doe',
167 title: 'Backend Software Engineer — Go',
168 place: null,
169 skills: ['Go', 'SQL', 'PG', 'Redis', 'Kafka', 'CH', 'k8s'],
170 english: 'EN C1',
171 },
172 variants: variantsOf(files, applications),
173 applications: sortedApplicationsOf(applications),
174 leads,
175 scanned_ms: now - 3 * HOUR_MS,
176 }
177}
178hooks/panel.tsx 519 lines1import type { ClientElements, ClientModule, RenderElement } from 'claude-code'
2
3import {
4 DEFAULT_LAYOUT,
5 FRESH_MS,
6 STAGE_LABEL,
7 daysSinceOf,
8 funnelOf,
9 isActive,
10 isSilent,
11 lastDayOf,
12 nextStageOf,
13 shortDayOf,
14 type Application,
15 type Layout,
16 type Lead,
17 type PanelProps,
18 type Stage,
19} from './hunt'
20import { ACCENT, SILENCE, SPINNER, STAGE_LOOK, agoOf, dayOf, fitBarOf, fitOf, minutesOf, type Cell } from './paint'
21
22type Item = { id: string; application: Application; lead?: undefined } | { id: string; lead: Lead; application?: undefined }
23type Box = { tick: number; pickId: string | null; pickIndex: number; ids: string[]; isBusy: boolean }
24type State = { tick: number; box: Box }
25type Post = (act: string, id?: string) => void
26type Pick = (id: string, index: number) => void
27type LeadGroup = { label: string | null; note: string; isRemote: boolean | null }
28type ShownGroup = LeadGroup & { count: number; shown: Lead[] }
29
30const TICK_MS = 100
31const MIN_COLUMNS = 30
32const HOTKEYS = '123456789'
33const ANON_KEY = 'p'
34const INLINE_LEADS = 4
35const RELOCATE: LeadGroup = { label: 'RELOCATE', note: 'visa or move', isRemote: false }
36const REMOTE: LeadGroup = { label: 'REMOTE', note: 'from home', isRemote: true }
37const LEAD_GROUPS: Record<Layout, readonly LeadGroup[]> = {
38 'one list': [{ label: null, note: '', isRemote: null }],
39 'relocate first': [RELOCATE, REMOTE],
40 'remote first': [REMOTE, RELOCATE],
41}
42const CLOSE_COLUMNS = 2
43const FIT_COLUMNS = 5
44const COMPANY_COLUMNS = 10
45const INDENT = ' '
46const FUNNEL_LABEL: Partial<Record<Stage, string>> = { applied: 'sent', screen: 'screen', interview: 'interview', offer: 'offer' }
47
48const Panel: ClientModule<PanelProps, State> = (given, surface) => {
49 const props: PanelProps = { ...given, layout: given.layout ?? DEFAULT_LAYOUT }
50 let box = surface.state?.box
51 if (!box) {
52 const created: Box = { tick: 0, pickId: null, pickIndex: 0, ids: [], isBusy: false }
53 surface.every(TICK_MS, () => {
54 if (created.isBusy) {
55 created.tick += 1
56 surface.setState({ tick: created.tick, box: created })
57 }
58 })
59 surface.onKey(event => {
60 if (event.key === ANON_KEY) {
61 surface.post({ act: 'anon' })
62
63 return
64 }
65 const step = event.key === 'j' || event.key === 'down' ? 1 : event.key === 'k' || event.key === 'up' ? -1 : 0
66 if (step !== 0 && created.ids.length > 0) {
67 const index = Math.min(created.ids.length - 1, Math.max(0, indexOf(created) + step))
68 created.pickId = created.ids[index] ?? null
69 created.pickIndex = index
70 surface.setState({ tick: created.tick, box: created })
71 }
72 })
73 surface.setState({ tick: 0, box: created })
74 box = created
75 }
76
77 const state = box
78 const width = Math.max(MIN_COLUMNS, surface.columns > 0 ? surface.columns : props.columns)
79 const ui = surface.elements
80 const { Box: Column, Text } = ui
81 const post: Post = (act, id) => surface.post(id === undefined ? { act } : { act, id })
82 const pick: Pick = (id, index) => {
83 state.pickId = id
84 state.pickIndex = index
85 surface.setState({ tick: state.tick, box: state })
86 }
87
88 const active = props.applications.filter(isActive)
89 const closed = props.applications.filter(app => !isActive(app))
90 const layoutGroups = LEAD_GROUPS[props.layout] ?? LEAD_GROUPS[DEFAULT_LAYOUT]
91 const groups: ShownGroup[] = layoutGroups.map(group => {
92 const all = props.leads.filter(lead => group.isRemote === null || lead.remote === group.isRemote)
93 const inline = Math.floor(INLINE_LEADS / layoutGroups.length)
94
95 return { ...group, count: all.length, shown: props.isDocked ? all : all.slice(0, inline) }
96 })
97 const isRemoteMarked = layoutGroups.length === 1
98 const leads = groups.flatMap(group => group.shown)
99 const items: Item[] = [
100 ...active.map(application => ({ id: `app:${application.id}`, application })),
101 ...leads.map(lead => ({ id: `lead:${lead.id}`, lead })),
102 ]
103 state.isBusy = props.busy !== null
104 state.ids = items.map(item => item.id)
105 const selected = props.isDocked || state.pickId !== null ? items[indexOf(state)] : undefined
106
107 const rows: RenderElement[] = [headOf(ui, props, state.tick, state.isBusy, width, active.length)]
108 if (props.busy) {
109 rows.push(
110 <Text color={ACCENT}>{fitOf(`${props.busy.label}… ${minutesOf(props.now - props.busy.since_ms)}`, width)}</Text>,
111 )
112 }
113
114 if (!props.hasRoot) {
115 rows.push(<Text> </Text>, ...welcomeOf(ui, width, props.busy !== null, post))
116
117 return <Column flexDirection="column" width={width}>{rows}</Column>
118 }
119
120 if (props.isDocked) {
121 rows.push(<Text> </Text>, ...resumeOf(ui, props, width))
122 }
123
124 rows.push(<Text> </Text>, ...applicationsOf(ui, props, active, closed, width))
125 active.forEach((application, index) => {
126 const isSelected = selected?.application === application
127 rows.push(applicationRowOf(ui, application, index, isSelected, props.now, width, pick))
128 if (isSelected) {
129 rows.push(...applicationDetailOf(ui, application, width, post))
130 }
131 })
132 if (closed.length > 0) {
133 rows.push(closedOf(ui, closed, width))
134 }
135
136 rows.push(<Text> </Text>, leadsHeadOf(ui, props, width, post))
137 if (leads.length === 0) {
138 rows.push(<Text dimColor wrap="wrap"> empty — press s and Claude searches for jobs that fit the resume</Text>)
139 } else {
140 let first = active.length
141 for (const group of groups) {
142 if (group.label !== null) {
143 rows.push(groupHeadOf(ui, group))
144 }
145 group.shown.forEach((lead, offset) => {
146 const index = active.length + leads.indexOf(lead)
147 const isSelected = selected?.lead === lead
148 const onPick = () => pick(`lead:${lead.id}`, index)
149 rows.push(leadRowOf(ui, lead, first + offset, isSelected, isRemoteMarked, props.now, width, onPick))
150 if (isSelected) {
151 rows.push(...leadDetailOf(ui, lead, width, props.busy !== null, post))
152 }
153 })
154 first += group.count
155 }
156 }
157 if (!props.isDocked && props.leads.length > leads.length) {
158 rows.push(<Text dimColor>{` ${props.leads.length - leads.length} more in the dock on the right`}</Text>)
159 }
160
161 return <Column flexDirection="column" width={width}>{rows}</Column>
162}
163
164export default Panel
165
166function indexOf(box: Box): number {
167 const found = box.pickId === null ? -1 : box.ids.indexOf(box.pickId)
168
169 return found >= 0 ? found : Math.max(0, Math.min(box.pickIndex, box.ids.length - 1))
170}
171
172function lineOf(ui: ClientElements, width: number, left: RenderElement[], right?: RenderElement): RenderElement {
173 const { Box } = ui
174
175 return (
176 <Box width={width} justifyContent="space-between">
177 <Box>{left}</Box>
178 {right ?? null}
179 </Box>
180 )
181}
182
183function headOf(ui: ClientElements, props: PanelProps, tick: number, isBusy: boolean, width: number, activeCount: number): RenderElement {
184 const { Text } = ui
185 const glyph = isBusy ? SPINNER[tick % SPINNER.length] : '◆'
186
187 return lineOf(
188 ui,
189 width - CLOSE_COLUMNS,
190 [<Text color={ACCENT}>{glyph}</Text>, <Text bold> job-hunt</Text>, <Text dimColor>{` · ${activeCount} active`}</Text>],
191 <Text dimColor>{dayOf(props.now)}</Text>,
192 )
193}
194
195function welcomeOf(ui: ClientElements, width: number, isBusy: boolean, post: Post): RenderElement[] {
196 const { Button, Text } = ui
197 const noteOf = (text: string) => wrapOf(text, width - INDENT.length, 3).map(line => <Text dimColor>{`${INDENT}${line}`}</Text>)
198
199 return [
200 <Text bold>No resume.txt in this folder yet.</Text>,
201 <Text> </Text>,
202 <Button key="hunt:init" hotkey="i" plain dimColor={isBusy} onPress={() => post('init')}>set up this folder</Button>,
203 ...noteOf('Claude turns your resume (PDF, DOCX, Markdown or a LinkedIn export) into resume.txt'),
204 <Text> </Text>,
205 <Button key="hunt:demo" hotkey="d" plain onPress={() => post('anon')}>try the demo</Button>,
206 ...noteOf('made-up data, nothing is written; p switches back'),
207 ]
208}
209
210function sectionOf(ui: ClientElements, title: string, note: string): RenderElement[] {
211 const { Text } = ui
212
213 return [<Text bold color={ACCENT}>{title}</Text>, <Text dimColor>{` ${note}`}</Text>]
214}
215
216function resumeOf(ui: ClientElements, props: PanelProps, width: number): RenderElement[] {
217 const { Box, Text } = ui
218 const profile = props.profile
219 const title = profile ? profile.title : 'resume.txt is unreadable'
220 const rows: RenderElement[] = [
221 <Box>
222 <Text bold color={ACCENT}>RESUME</Text>
223 <Text bold>{` ${fitOf(title, width - 8).trimEnd()}`}</Text>
224 </Box>,
225 ]
226 const variants = props.variants
227 const nameWidth = Math.min(14, Math.max(6, ...variants.map(variant => [...variant.name].length)))
228 const formatWidth = Math.max(3, width - nameWidth - 12)
229 const newest = Math.max(0, ...variants.map(variant => variant.updated_ms))
230
231 for (const variant of variants) {
232 const isNewest = variant.updated_ms === newest
233 rows.push(
234 <Box>
235 <Text> </Text>
236 <Text bold={isNewest} color={isNewest ? ACCENT : undefined}>{fitOf(variant.name, nameWidth)}</Text>
237 <Text> </Text>
238 <Text dimColor>{fitOf(variant.formats.join(' '), formatWidth)}</Text>
239 <Text> </Text>
240 <Text dimColor>{dayOf(variant.updated_ms)}</Text>
241 <Text color="cyan">{(variant.sent > 0 ? `→${variant.sent}` : '').padStart(4)}</Text>
242 </Box>,
243 )
244 }
245
246 if (profile && profile.skills.length > 0) {
247 const tail = profile.english ? ` · ${profile.english}` : ''
248 rows.push(<Text dimColor>{fitOf(` ${profile.skills.join(' ')}${tail}`, width)}</Text>)
249 }
250
251 return rows
252}
253
254function applicationsOf(
255 ui: ClientElements,
256 props: PanelProps,
257 active: Application[],
258 closed: Application[],
259 width: number,
260): RenderElement[] {
261 const { Box, Text } = ui
262 const silent = active.filter(app => isSilent(app, props.now)).length
263 const rows: RenderElement[] = [
264 lineOf(
265 ui,
266 width,
267 sectionOf(ui, 'APPLIED', `${active.length} active · ${closed.length} closed`),
268 silent > 0 ? <Text color={SILENCE}>{`⚑ ${silent} silent`}</Text> : undefined,
269 ),
270 ]
271
272 if (props.isDocked && props.applications.length > 0) {
273 const steps = funnelOf(props.applications).flatMap((step, index) => [
274 <Text dimColor>{`${index > 0 ? ' › ' : ' '}${FUNNEL_LABEL[step.stage] ?? step.stage} `}</Text>,
275 step.count > 0
276 ? <Text bold color={STAGE_LOOK[step.stage].color}>{String(step.count)}</Text>
277 : <Text dimColor>0</Text>,
278 ])
279 rows.push(<Box>{steps}</Box>)
280 }
281
282 if (active.length === 0) {
283 rows.push(<Text dimColor> nothing active — press a on a job to log an application</Text>)
284 }
285
286 return rows
287}
288
289function buttonOf(ui: ClientElements, key: string, index: number, label: string, onPress: () => void): RenderElement[] {
290 const { Button, Text } = ui
291 const hotkey = HOTKEYS[index]
292
293 return hotkey
294 ? [<Button key={key} hotkey={hotkey} plain onPress={onPress}>{label}</Button>]
295 : [<Text>{' '}</Text>, <Button key={key} plain onPress={onPress}>{label}</Button>]
296}
297
298function markerOf(ui: ClientElements, isSelected: boolean, isFresh: boolean): RenderElement {
299 const { Text } = ui
300
301 return <Text color={ACCENT} bold>{isSelected ? '›' : isFresh ? '•' : ' '}</Text>
302}
303
304function applicationRowOf(
305 ui: ClientElements,
306 app: Application,
307 index: number,
308 isSelected: boolean,
309 now: number,
310 width: number,
311 pick: Pick,
312): RenderElement {
313 const { Box, Text } = ui
314 const look = STAGE_LOOK[app.stage]
315 const days = daysSinceOf(lastDayOf(app) ?? '', now)
316 const isQuiet = isSilent(app, now)
317 const status = isQuiet ? `⚑ ${days}d` : `${STAGE_LABEL[app.stage]} ${days}d`
318 const labelWidth = Math.max(8, width - 1 - 3 - 1 - 2 - [...status].length)
319 const label = fitOf(`${fitOf(app.company, COMPANY_COLUMNS)} ${app.role}`, labelWidth)
320 const id = `app:${app.id}`
321
322 return (
323 <Box>
324 {markerOf(ui, isSelected, false)}
325 {buttonOf(ui, `hunt:${id}`, index, label, () => pick(id, index))}
326 <Text> </Text>
327 <Text color={look.color}>{look.glyph}</Text>
328 <Text color={isQuiet ? SILENCE : undefined} dimColor={!isQuiet}>{` ${status}`}</Text>
329 </Box>
330 )
331}
332
333function applicationDetailOf(ui: ClientElements, app: Application, width: number, post: Post): RenderElement[] {
334 const { Text } = ui
335 const next = nextStageOf(app.stage)
336 const trail = app.history.map(step => `${shortDayOf(step.on)} ${STAGE_LABEL[step.stage]}`).join(' → ')
337 const extra = [app.resume ? `resume ${app.resume}` : '', app.note ?? ''].filter(Boolean).join(' · ')
338 const rows: RenderElement[] = [<Text dimColor>{fitOf(`${INDENT}${trail}`, width)}</Text>]
339
340 if (extra) {
341 rows.push(<Text dimColor>{fitOf(`${INDENT}${extra}`, width)}</Text>)
342 }
343
344 rows.push(
345 actionsOf(ui, [
346 next ? { key: 'next', hotkey: 'n', label: `→ ${STAGE_LABEL[next]}`, onPress: () => post('next', app.id) } : undefined,
347 { key: 'reject', hotkey: 'r', label: 'rejected', onPress: () => post('reject', app.id) },
348 ]),
349 actionsOf(ui, [
350 { key: 'close', hotkey: 'c', label: 'close', onPress: () => post('close', app.id) },
351 app.url ? { key: 'open-app', hotkey: 'o', label: 'open', onPress: () => post('open', `app:${app.id}`) } : undefined,
352 ]),
353 )
354
355 return rows
356}
357
358function closedOf(ui: ClientElements, closed: Application[], width: number): RenderElement {
359 const { Box, Text } = ui
360 const parts: RenderElement[] = [<Text> </Text>]
361 let used = 1
362
363 for (const [index, app] of closed.entries()) {
364 const look = STAGE_LOOK[app.stage]
365 const text = ` ${app.company} `
366 const rest = closed.length - index
367 if (used + 1 + [...text].length > width - 4 && rest > 0) {
368 parts.push(<Text dimColor>{`+${rest}`}</Text>)
369 break
370 }
371 parts.push(<Text color={look.color} dimColor>{look.glyph}</Text>, <Text dimColor>{text}</Text>)
372 used += 1 + [...text].length
373 }
374
375 return <Box>{parts}</Box>
376}
377
378function leadsHeadOf(ui: ClientElements, props: PanelProps, width: number, post: Post): RenderElement {
379 const { Button } = ui
380 const scanned = props.scanned_ms === null ? 'not scanned yet' : `scanned ${agoOf(props.scanned_ms, props.now)}`
381
382 return lineOf(
383 ui,
384 width,
385 sectionOf(ui, 'JOBS', `${props.leads.length} · ${scanned}`),
386 <Button key="hunt:scan" hotkey="s" plain dimColor={props.busy !== null} onPress={() => post('scan')}>search</Button>,
387 )
388}
389
390function groupHeadOf(ui: ClientElements, group: ShownGroup): RenderElement {
391 const { Box, Text } = ui
392
393 return (
394 <Box>
395 <Text bold>{` ${group.label}`}</Text>
396 <Text dimColor>{` ${group.count} · ${group.note}`}</Text>
397 </Box>
398 )
399}
400
401function leadRowOf(
402 ui: ClientElements,
403 lead: Lead,
404 row: number,
405 isSelected: boolean,
406 isRemoteMarked: boolean,
407 now: number,
408 width: number,
409 onPick: () => void,
410): RenderElement {
411 const { Box, Text } = ui
412 const mark = lead.remote && isRemoteMarked ? '⌂' : lead.sponsor ? '✈' : ' '
413 const labelWidth = Math.max(8, width - 1 - 3 - 1 - FIT_COLUMNS - 1 - 4)
414 const label = fitOf(`${fitOf(lead.company, COMPANY_COLUMNS)} ${lead.title}`, labelWidth)
415
416 return (
417 <Box>
418 {markerOf(ui, isSelected, now - lead.found_ms < FRESH_MS)}
419 {buttonOf(ui, `hunt:lead:${lead.id}`, row, label, onPick)}
420 <Text> </Text>
421 {cellsOf(ui, fitBarOf(lead.fit, FIT_COLUMNS))}
422 <Text dimColor>{` ${fitOf(lead.country, 3)}`}</Text>
423 <Text color={ACCENT}>{mark}</Text>
424 </Box>
425 )
426}
427
428function leadDetailOf(ui: ClientElements, lead: Lead, width: number, isBusy: boolean, post: Post): RenderElement[] {
429 const { Text } = ui
430 const facts = [`fit ${lead.fit}`, lead.salary ?? '', lead.location, lead.posted ? shortDayOf(lead.posted) : '']
431 .filter(Boolean)
432 .join(' · ')
433 const rows: RenderElement[] = wrapOf(facts, width - INDENT.length, 2).map(line => <Text>{`${INDENT}${line}`}</Text>)
434
435 for (const line of wrapOf(lead.why ?? '', width - INDENT.length, 2)) {
436 rows.push(<Text dimColor>{`${INDENT}${line}`}</Text>)
437 }
438
439 rows.push(
440 actionsOf(ui, [
441 { key: 'apply', hotkey: 'a', label: 'applied', onPress: () => post('apply', lead.id) },
442 { key: 'tailor', hotkey: 't', label: 'tailor', dimColor: isBusy, onPress: () => post('tailor', lead.id) },
443 ]),
444 actionsOf(ui, [
445 { key: 'hide', hotkey: 'x', label: 'hide', onPress: () => post('hide', lead.id) },
446 { key: 'open-lead', hotkey: 'o', label: 'open', onPress: () => post('open', `lead:${lead.id}`) },
447 ]),
448 )
449
450 return rows
451}
452
453type Action = { key: string; hotkey: string; label: string; dimColor?: boolean; onPress: () => void }
454
455function actionsOf(ui: ClientElements, actions: (Action | undefined)[]): RenderElement {
456 const { Box, Button, Text } = ui
457 const parts: RenderElement[] = [<Text>{INDENT}</Text>]
458
459 for (const action of actions) {
460 if (!action) {
461 continue
462 }
463 if (parts.length > 1) {
464 parts.push(<Text>{' '}</Text>)
465 }
466 parts.push(
467 <Button key={`hunt:${action.key}`} hotkey={action.hotkey} plain dimColor={action.dimColor === true} onPress={action.onPress}>
468 {action.label}
469 </Button>,
470 )
471 }
472
473 return <Box>{parts}</Box>
474}
475
476function wrapOf(text: string, width: number, maxLines: number): string[] {
477 const lines: string[] = []
478 let line = ''
479
480 for (const word of text.split(/\s+/).filter(Boolean)) {
481 const next = line ? `${line} ${word}` : word
482 if ([...next].length > width && line) {
483 lines.push(line)
484 line = word
485 } else {
486 line = next
487 }
488 }
489 if (line) {
490 lines.push(line)
491 }
492 if (lines.length <= maxLines) {
493 return lines
494 }
495
496 const kept = lines.slice(0, maxLines)
497 kept[maxLines - 1] = fitOf(lines.slice(maxLines - 1).join(' '), width).trimEnd()
498
499 return kept
500}
501
502function cellsOf(ui: ClientElements, cells: readonly Cell[]): RenderElement[] {
503 const { Text } = ui
504 const groups: Cell[] = []
505
506 for (const cell of cells) {
507 const last = groups[groups.length - 1]
508 if (last && last.color === cell.color && last.dim === cell.dim && last.bold === cell.bold) {
509 last.ch += cell.ch
510 } else {
511 groups.push({ ...cell })
512 }
513 }
514
515 return groups.map(group => (
516 <Text color={group.color} dimColor={group.dim} bold={group.bold}>{group.ch}</Text>
517 ))
518}
519hooks/paint.ts 94 lines1import type { Stage } from './hunt'
2
3export type Cell = { ch: string; color?: string; dim?: boolean; bold?: boolean }
4
5export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
6export const ACCENT = '#f5b942'
7export const SILENCE = '#e3c35a'
8export const STAGE_LOOK: Record<Stage, { glyph: string; color: string }> = {
9 applied: { glyph: '○', color: '#6ea8fe' },
10 screen: { glyph: '◐', color: '#4fd1c5' },
11 interview: { glyph: '●', color: '#c084fc' },
12 offer: { glyph: '★', color: '#4ade80' },
13 rejected: { glyph: '✗', color: '#f87171' },
14 closed: { glyph: '◌', color: '#7b8190' },
15}
16
17const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
18const MINUTE_MS = 60_000
19const HOUR_MS = 60 * MINUTE_MS
20const DAY_MS = 24 * HOUR_MS
21
22export function hexOf(hue: number, saturation: number, lightness: number): string {
23 const h = ((hue % 360) + 360) % 360
24 const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation
25 const x = chroma * (1 - Math.abs(((h / 60) % 2) - 1))
26 const m = lightness - chroma / 2
27 const [r, g, b] =
28 h < 60 ? [chroma, x, 0]
29 : h < 120 ? [x, chroma, 0]
30 : h < 180 ? [0, chroma, x]
31 : h < 240 ? [0, x, chroma]
32 : h < 300 ? [x, 0, chroma]
33 : [chroma, 0, x]
34 const byte = (v: number) => Math.round(Math.min(1, Math.max(0, v + m)) * 255).toString(16).padStart(2, '0')
35
36 return `#${byte(r)}${byte(g)}${byte(b)}`
37}
38
39export function fitColorOf(fit: number): string {
40 return hexOf((Math.min(100, Math.max(0, fit)) / 100) * 130, 0.7, 0.55)
41}
42
43export function fitBarOf(fit: number, width: number): Cell[] {
44 const filled = (Math.min(100, Math.max(0, fit)) / 100) * width
45 const full = Math.floor(filled)
46 const partial = Math.floor((filled - full) * 8)
47 const color = fitColorOf(fit)
48 const cells: Cell[] = []
49
50 for (let i = 0; i < width; i++) {
51 if (i < full) {
52 cells.push({ ch: '█', color })
53 } else if (i === full && partial > 0) {
54 cells.push({ ch: EIGHTHS[partial] ?? ' ', color })
55 } else {
56 cells.push({ ch: '·', dim: true })
57 }
58 }
59
60 return cells
61}
62
63export function fitOf(text: string, width: number): string {
64 if (width <= 0) {
65 return ''
66 }
67 const chars = [...text]
68
69 return chars.length <= width ? text.padEnd(width) : `${chars.slice(0, width - 1).join('')}…`
70}
71
72export function agoOf(ms: number | null, now: number): string {
73 if (ms === null) {
74 return 'never'
75 }
76 const passed = Math.max(0, now - ms)
77
78 return passed < MINUTE_MS ? 'just now'
79 : passed < HOUR_MS ? `${Math.floor(passed / MINUTE_MS)}m ago`
80 : passed < DAY_MS ? `${Math.floor(passed / HOUR_MS)}h ago`
81 : `${Math.floor(passed / DAY_MS)}d ago`
82}
83
84export function minutesOf(ms: number): string {
85 return `${Math.max(0, Math.floor(ms / MINUTE_MS))} min`
86}
87
88export function dayOf(ms: number): string {
89 const date = new Date(ms)
90 const two = (v: number) => String(v).padStart(2, '0')
91
92 return `${two(date.getUTCDate())}.${two(date.getUTCMonth() + 1)}`
93}
94types/index.d.ts 56 lines1export type Stage = 'applied' | 'screen' | 'interview' | 'offer' | 'rejected' | 'closed'
2
3export type Step = { stage: Stage; on: string }
4
5export type Application = {
6 id: string
7 company: string
8 role: string
9 url: string | null
10 resume: string | null
11 stage: Stage
12 history: Step[]
13 note: string | null
14}
15
16export type Tracker = { v: 1; applications: Application[]; hidden: string[] }
17
18export type Lead = {
19 id: string
20 company: string
21 title: string
22 url: string
23 location: string
24 country: string
25 sponsor: boolean
26 remote: boolean
27 salary: string | null
28 posted: string | null
29 fit: number
30 why: string | null
31 found_ms: number
32}
33
34export type Leads = { v: 1; scanned_ms: number | null; query: string | null; leads: Lead[] }
35
36export type Profile = { name: string; title: string; place: string | null; skills: string[]; english: string | null }
37
38export type FileEntry = { dir: string; name: string; mtime_ms: number }
39
40export type Busy = { kind: 'init' | 'scan' | 'tailor'; label: string; since_ms: number }
41
42export type Pending = { busy: Busy; text: string; turnId: string | null; done: string | null }
43
44export type Folder = { root: string; profile: Profile | null; files: FileEntry[]; tracker: Tracker; leads: Leads }
45
46declare module 'claude-code' {
47 interface PluginState {
48 'job-hunt': {
49 folder: Folder
50 now: number
51 pending: Pending | null
52 isDemo: boolean
53 }
54 }
55}
56