SLOPSHOPPER

breadcrumbs-launcher

Starts breadcrumbs' manual-test testers the moment you press Start, without going through the main agent.

newprocesstimer
★ 1v0.1.0no licenseupdated 2026-10-12crockalet/claude-mods/plugins/breadcrumbs-launcher
A shopper browsing a rack in a slop shop
README

breadcrumbs-launcher

Lets breadcrumbs start a manual-test tester the moment you press Start tester, even while Claude is mid-turn.

A Claude Code mod can't answer tool calls from an agent it spawned itself: that agent skips every hook of the mod that spawned it. So breadcrumbs used to ask the main agent to spawn each tester, and you waited until Claude was free. This mod does the spawning instead. Breadcrumbs leaves a request under ~/.agents/notes/.launch/<session>/, this mod picks it up within a second, and breadcrumbs answers the tester's tools as usual. The tester's final report still goes to the main session.

It starts only breadcrumbs:tester, and nothing else. Breadcrumbs falls back to asking Claude if this mod isn't running.

Install

/plugin install breadcrumbs-launcher@claude-mods
Source 1 files
hooks/register.ts 80 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// Breadcrumbs writes spawn requests here and checks `ready` to know this mod runs; see LAUNCH_DIR in its hooks/tests.ts.
4const LAUNCH_DIR = '.agents/notes/.launch'
5const TESTER = 'breadcrumbs:tester'
6const POLL_MS = 1000
7const HEARTBEAT_MS = 30_000
8const STALE_MS = 7 * 86_400_000
9
10type $ = EngineInterface
11type Request = { subagentType?: unknown; description?: unknown; prompt?: unknown }
12
13let dir: string | null = null
14
15const root = async ($: $) => `${(await $.env.get('HOME')) ?? '~'}/${LAUNCH_DIR}`
16
17const stamp = async ($: $) => {
18  if (dir) await $.fs.write(`${dir}/ready`, String(await $.clock.now()))
19}
20
21// Folders of sessions that ended long ago, which nothing else removes.
22const tidy = async ($: $, now: number) => {
23  let sessions
24  try {
25    sessions = await $.fs.list(await root($))
26  } catch {
27    return
28  }
29  for (const s of sessions) {
30    const path = `${await root($)}/${s.name}`
31    if (s.kind !== 'dir' || path === dir) continue
32    const at = await $.fs.stat(`${path}/ready`).then(r => r.mtimeMs, () => 0)
33    if (now - at > STALE_MS) await $.process.run(['rm', '-rf', path])
34  }
35}
36
37// Runs from this mod's own timer: a spawn breadcrumbs causes, even through a command, skips every breadcrumbs hook, so the tester's tools would go unanswered.
38const poll = async ($: $) => {
39  if (!dir) return
40  let entries
41  try {
42    entries = await $.fs.list(dir)
43  } catch {
44    return
45  }
46  for (const entry of entries) {
47    if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
48    const path = `${dir}/${entry.name}`
49    let request: Request
50    try {
51      request = JSON.parse(await $.fs.read(path)) as Request
52    } catch {
53      continue
54    }
55    // Taken before spawning, so breadcrumbs' fallback never starts the same tester twice.
56    await $.process.run(['rm', '-f', path])
57    if (request.subagentType !== TESTER || typeof request.prompt !== 'string') continue
58    const description = typeof request.description === 'string' ? request.description : 'Manual test'
59    try {
60      await $.agent.spawn({ subagentType: TESTER, prompt: request.prompt, description })
61    } catch (error) {
62      $.ui.log(`breadcrumbs-launcher: could not start "${description}" (${error instanceof Error ? error.message : String(error)})`)
63    }
64  }
65}
66
67export const register: Register = on => {
68  on('session.start', async ($, e, next) => {
69    const started = await next(e)
70    dir = `${await root($)}/${await $.session.id()}`
71    await stamp($)
72    $.clock.every(POLL_MS, () => poll($))
73    $.clock.every(HEARTBEAT_MS, () => stamp($))
74    const now = await $.clock.now()
75    $.clock.after(10_000, () => tidy($, now))
76
77    return started
78  })
79}
80