SLOPSHOPPER

parallel-pacer

Watches this PC's CPU, free memory and parallel chats, holds heavy commands back to one at a time when it is choked, and shows how many more chats fit. English…

newguardcommandtoaststatusprompt
v0.2.0NOASSERTIONupdated 2026-10-06nakadaharuki/parallel-pacer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · parallel-pacer
› fix the failing auth test and add an audit log call ⏺ 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 › /para ⎿ parallel-pacer: 1 chat (0 busy) · CPU 0% · 16 GB free · calm, room for 3 more ⎿ parallel-pacer: Heavy work: 0 in other chats, 0 in this one ⎿ parallel-pacer: Rule: tight = under 3.5 GB free or CPU 75%+ / choked = under 2 GB free or CPU 90%+ ⎿ parallel-pacer: Pacing: on ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ parallel-pacer: 1 chat (0 busy) · CPU 0% · 16 GB free · calm, room for 3 more
README

parallel-pacer

Run several Claude Code chats side by side and their npm installs, builds and tests pile up until the PC freezes. This mod watches the PC's CPU, free memory and the chats running on it, holds heavy commands back to one at a time while the PC is choked, and says in the status line how many more chats fit.

3 chats (2 busy) · CPU 62% · 4.1 GB free · calm, room for 2 more

For Windows (it measures with PowerShell's Get-CimInstance). English and Japanese: the language option (auto, en, ja) follows Claude Code's language setting on auto.

What it does

  • Status line: chats in parallel, CPU, free memory, and how many more chats fit
  • Heavy work waits its turn: while the PC is choked and another chat runs heavy work (install, build, test, typecheck, dev server…), a new heavy command is held back and Claude is told to run it again in 1 to 2 minutes. After 3 holds within 10 minutes it is let through, so nothing is stuck for good
  • A note for Claude: only while the PC is choked, a note asks Claude to run heavy work one at a time (without cutting the request)
  • Toast: once, when the PC turns tight or choked
  • /para: the state and the rule. /para off stops the pacing, /para on brings it back

The thresholds suit a 16 GB laptop (tight = under 3.5 GB free or CPU 75%+, choked = under 2 GB free or CPU 90%+). Change them in the constants at the top of hooks/register.tsx.

Install

/plugin marketplace add nakadaharuki/parallel-pacer
/plugin install parallel-pacer@parallel-pacer

Claude Code v2.1.287 or later. It is also listed, pinned to a read commit with a manifest, at modscode.com/mods/parallel-pacer.

What it touches

Mods are not sandboxed, so here it is before you install. The code is one file, hooks/register.tsx.

  • No network ($.http is not used). No environment variables, no files
  • One outside process: a reading every 30 seconds (powershell -NoProfile -Command "Get-CimInstance ..."). However many chats run, the PC takes one reading (they take turns through $.store)
  • $.store holds the reading, a heartbeat per chat, and the kind of heavy command running (npm install and the like, never its arguments)
  • It reads Claude Code's language setting to pick the language
  • It watches Bash and PowerShell tool calls and may hold back heavy commands

How it works

A mod runs once per session, in a process of its own. So what is one per PC (the reading, the count of heavy work) lives in $.store, which every chat reads, not in module variables. A chat whose heartbeat stops for 60 seconds is no longer counted.

日本語

PC の CPU・空きメモリと並列の会話数を見て、詰まっている間は重い処理(install・build・test など)を 1 本ずつに絞り、ステータス行に「あと何本並べられるか」を出します。表示は日本語にもなります(language の設定が auto なら Claude Code の language 設定に合わせる)。成分表と入れ方は modscode.com/ja/mods/parallel-pacer。デスクトップ版の Mods で踏んだ罠は Zenn に: https://zenn.dev/nakadaharuki

All rights reserved(著作権は nakadadev。読むことと Claude Code に入れて使うことはできますが、複製・改変・再配布は許可していません)

Source 1 files
hooks/register.tsx 267 lines
1// parallel-pacer: watches this PC's CPU and free memory and the chats running on it,
2// holds back heavy commands (installs, builds, tests, dev servers) when the PC is
3// choked, and says how many more chats it can take.
4//
5// All chats share one reading through $.store: whichever chat finds the reading
6// older than SAMPLE_MS takes the next one, so the sampler runs once per PC, not per chat.
7
8const SAMPLE_MS = 30_000
9const BEAT_MS = 20_000
10const LIVE_MS = 60_000 // a chat with no heartbeat for this long is gone
11const HEAVY_TTL_MS = 10 * 60_000 // a heavy command left in the background counts this long
12const PASS_AFTER = 3 // denials within PASS_WINDOW before a command is let through anyway
13const PASS_WINDOW_MS = 10 * 60_000
14
15// Thresholds for a 16 GB laptop (i5-1235U). Free memory in GB, CPU in percent (smoothed).
16const OVER = { freeGb: 2, cpu: 90 }
17const TIGHT = { freeGb: 3.5, cpu: 75 }
18const GB_PER_CHAT = 1 // a chat plus what it runs, roughly
19
20const HEAVY =
21  /\b(npm (i|install|ci|run (build|test|typecheck|dev|check))|pnpm (i|install|build|test|dev|typecheck)|yarn (install|build|test|dev)|tsc\b|vitest|jest|vite build|next build|wrangler dev|cargo (build|test)|docker build|playwright test)/i
22
23type Load = { at: number; cpu: number; freeGb: number }
24type Beat = { at: number; busy: boolean }
25type Heavy = { at: number; cmd: string }
26type Level = 'ok' | 'tight' | 'over'
27
28const SAMPLE = [
29  'powershell',
30  '-NoProfile',
31  '-Command',
32  '$o=Get-CimInstance Win32_OperatingSystem;$c=(Get-CimInstance Win32_Processor|Measure-Object LoadPercentage -Average).Average;"$c $($o.FreePhysicalMemory)"',
33]
34
35// The words shown, in English or Japanese. With the language option on auto they follow
36// Claude Code's own language setting ("japanese" and the like), else English.
37const MESSAGES = {
38  en: {
39    level: { ok: 'calm', tight: 'tight', over: 'choked' } as Record<Level, string>,
40    chats: (n: number) => `${n} chat${n === 1 ? '' : 's'}`,
41    line: (chats: string, busy: number, cpu: number, free: number, lv: string, tail: string) => `${chats} (${busy} busy) · CPU ${cpu}% · ${free} GB free · ${lv}, ${tail}`,
42    more: (n: number) => `room for ${n} more`,
43    noMore: 'add no more',
44    oneByOne: 'heavy work one at a time',
45    toastOver: (cpu: number, free: number) => `This PC is choked (CPU ${cpu}%, ${free} GB free). Heavy work now runs one at a time. Start new chats on claude.ai/code`,
46    toastTight: (cpu: number, free: number) => `This PC is getting tight (CPU ${cpu}%, ${free} GB free). Do not add more chats`,
47    deny: (lv: string, cpu: number, free: number, heavy: number, after: number) =>
48      `parallel-pacer: this PC is ${lv} (CPU ${cpu}%, ${free} GB free, ${heavy} heavy run(s) in other chats). ` +
49      'Put this heavy command off, go on with other work first, and run the same command again in 1 to 2 minutes. ' +
50      `After ${after} holds it is let through.`,
51    note: (lv: string, cpu: number, free: number) =>
52      `[parallel-pacer] This PC is ${lv} (CPU ${cpu}%, ${free} GB free). ` +
53      'Run npm install, builds, tests, type checks and dev servers only as needed and one at a time, and stop a dev server once done. ' +
54      'One subagent at a time, and no Workflow. Keep the whole request; make the way of doing it lighter. The user does not see this note.',
55    cmdDesc: 'The parallel state (CPU, free memory, chats, heavy work). /para off stops the pacing, /para on brings it back',
56    on: 'Pacing is back on',
57    off: 'Pacing stopped (the status line goes too)',
58    heavyLine: (others: number, own: number) => `Heavy work: ${others} in other chats, ${own} in this one`,
59    rule: (t: typeof TIGHT, o: typeof OVER) => `Rule: tight = under ${t.freeGb} GB free or CPU ${t.cpu}%+ / choked = under ${o.freeGb} GB free or CPU ${o.cpu}%+`,
60    pacing: (on: boolean) => `Pacing: ${on ? 'on' : 'off (/para on brings it back)'}`,
61  },
62  ja: {
63    level: { ok: '余裕', tight: '詰まり気味', over: '詰まり' } as Record<Level, string>,
64    chats: (n: number) => `並列 ${n}本`,
65    line: (chats: string, busy: number, cpu: number, free: number, lv: string, tail: string) => `${chats}(動作${busy}) · CPU ${cpu}% · 空き ${free}GB · ${lv} ${tail}`,
66    more: (n: number) => `あと${n}本可`,
67    noMore: '増やさない',
68    oneByOne: '重い処理は1本ずつ',
69    toastOver: (cpu: number, free: number) => `PC が詰まっています(CPU ${cpu}%・空き ${free}GB)。重い処理を1本ずつに絞ります。新しい会話は claude.ai/code へ`,
70    toastTight: (cpu: number, free: number) => `PC が詰まり気味です(CPU ${cpu}%・空き ${free}GB)。これ以上会話を増やさないでください`,
71    deny: (lv: string, cpu: number, free: number, heavy: number, after: number) =>
72      `parallel-pacer: この PC が${lv}です(CPU ${cpu}%・空き ${free}GB・他の会話で重い処理 ${heavy} 本)。` +
73      'この重い処理は後回しにして、先に他の作業を進め、1〜2 分後に同じコマンドを再実行してください。' +
74      `${after} 回止められた後は通します。`,
75    note: (lv: string, cpu: number, free: number) =>
76      `[parallel-pacer] この PC が${lv}です(CPU ${cpu}%・空き ${free}GB)。` +
77      'npm install・ビルド・テスト・型チェック・dev server は必要な物だけを 1 本ずつ走らせ、終わった dev server は止めてください。' +
78      'サブエージェントは 1 体ずつ、Workflow は使わないでください。依頼の範囲は削らず、やり方を軽くしてください。このメモはユーザーには見えていません。',
79    cmdDesc: '並列の状態(CPU・空きメモリ・会話数・重い処理)を見る。/para off で調整を止め、/para on で戻す',
80    on: '並列の調整を戻しました',
81    off: '並列の調整を止めました(表示も消します)',
82    heavyLine: (others: number, own: number) => `重い処理: 他の会話 ${others} 本・この会話 ${own} 本`,
83    rule: (t: typeof TIGHT, o: typeof OVER) => `基準: 詰まり気味 = 空き ${t.freeGb}GB 未満か CPU ${t.cpu}% 以上 / 詰まり = 空き ${o.freeGb}GB 未満か CPU ${o.cpu}% 以上`,
84    pacing: (on: boolean) => `調整: ${on ? 'オン' : 'オフ(/para on で戻す)'}`,
85  },
86}
87let M = MESSAGES.en
88// "japanese", "日本語", "ja-JP" → Japanese; anything else → English
89const pickLang = (option: unknown, setting: unknown): 'en' | 'ja' => {
90  const v = typeof option === 'string' && option !== 'auto' ? option : setting
91  return typeof v === 'string' && /^(ja\b|ja[-_]|japanese|日本)/i.test(v.trim()) ? 'ja' : 'en'
92}
93
94let sid = ''
95let lastLevel: Level | null = null
96const denied: number[] = []
97
98const level = (l: Load | undefined): Level =>
99  !l ? 'ok' : l.freeGb < OVER.freeGb || l.cpu >= OVER.cpu ? 'over' : l.freeGb < TIGHT.freeGb || l.cpu >= TIGHT.cpu ? 'tight' : 'ok'
100
101// How many more chats fit: memory gives the room, CPU caps it.
102const room = (l: Load | undefined): number => {
103  if (!l) return 0
104  const byMem = Math.floor((l.freeGb - OVER.freeGb) / GB_PER_CHAT)
105  const byCpu = l.cpu < 50 ? 3 : l.cpu < 65 ? 2 : l.cpu < TIGHT.cpu ? 1 : 0
106  return Math.max(0, Math.min(byMem, byCpu))
107}
108
109async function sample($: any, at: number): Promise<Load | undefined> {
110  const old = (await $.store.get('load')) as Load | undefined
111  if (old && at - old.at < SAMPLE_MS) return old
112  await $.store.set('load', { ...(old ?? { cpu: 0, freeGb: 16 }), at }) // claim the turn before the slow read
113  try {
114    const r = await $.process.run(SAMPLE, { timeoutMs: 15_000 })
115    const [cpu, freeKb] = r.stdout.trim().split(/\s+/).map(Number)
116    if (!Number.isFinite(cpu) || !Number.isFinite(freeKb)) return old
117    // CPU load jumps from second to second; smooth it so one spike does not throttle
118    const smooth = old && old.cpu > 0 ? Math.round(old.cpu * 0.5 + cpu * 0.5) : cpu
119    const l = { at, cpu: smooth, freeGb: Math.round((freeKb / 1024 / 1024) * 10) / 10 }
120    await $.store.set('load', l)
121    return l
122  } catch {
123    return old
124  }
125}
126
127async function scan($: any, at: number) {
128  const keys: string[] = await $.store.keys()
129  let chats = 0
130  let busy = 0
131  const heavy: Heavy[] = []
132  for (const k of keys) {
133    if (k.startsWith('s:')) {
134      const b = (await $.store.get(k)) as Beat | undefined
135      if (!b || at - b.at > LIVE_MS) await $.store.delete(k)
136      else {
137        chats++
138        if (b.busy) busy++
139      }
140    } else if (k.startsWith('h:')) {
141      const h = (await $.store.get(k)) as Heavy | undefined
142      if (!h || at - h.at > HEAVY_TTL_MS) await $.store.delete(k)
143      else if (!k.startsWith(`h:${sid}:`)) heavy.push(h)
144    }
145  }
146  return { chats, busy, heavy }
147}
148
149function line(l: Load | undefined, chats: number, busy: number): string {
150  if (!l) return M.chats(chats)
151  const lv = level(l)
152  const more = room(l)
153  const tail = lv === 'ok' ? (more > 0 ? M.more(more) : M.noMore) : M.oneByOne
154  return M.line(M.chats(chats), busy, l.cpu, l.freeGb, M.level[lv], tail)
155}
156
157async function beat($: any, busy?: boolean) {
158  const at = await $.clock.now()
159  const old = (await $.store.get(`s:${sid}`)) as Beat | undefined
160  await $.store.set(`s:${sid}`, { at, busy: busy ?? old?.busy ?? false })
161  const l = await sample($, at)
162  const s = await scan($, at)
163  if ((await $.store.get('manage')) === false) {
164    $.ui.status(undefined)
165    return
166  }
167  $.ui.status(line(l, s.chats, s.busy))
168  const lv = level(l)
169  if (lastLevel !== null && lv !== lastLevel && lv !== 'ok')
170    $.ui.toast(
171      lv === 'over' ? M.toastOver(l!.cpu, l!.freeGb) : M.toastTight(l!.cpu, l!.freeGb),
172    )
173  lastLevel = lv
174}
175
176// Hold back a heavy command while others are running and the PC is choked
177async function gate($: any, e: any, next: any) {
178  const cmd: string = e.command ?? ''
179  if (!HEAVY.test(cmd) || (await $.store.get('manage')) === false) return next(e)
180  const at = await $.clock.now()
181  const l = (await $.store.get('load')) as Load | undefined
182  const lv = level(l)
183  const { heavy } = await scan($, at)
184  const limit = lv === 'over' ? 1 : lv === 'tight' ? 2 : Infinity
185  while (denied.length && at - denied[0] > PASS_WINDOW_MS) denied.shift()
186  if (heavy.length >= limit && denied.length < PASS_AFTER) {
187    denied.push(at)
188    return {
189      deny: M.deny(M.level[lv], l!.cpu, l!.freeGb, heavy.length, PASS_AFTER),
190    }
191  }
192  const key = `h:${sid}:${e.tool_use_id ?? at}`
193  await $.store.set(key, { at, cmd: HEAVY.exec(cmd)?.[0] ?? '' } /* the kind of command only: arguments may carry secrets */)
194  try {
195    return await next(e)
196  } finally {
197    // a background run keeps its slot until HEAVY_TTL_MS; a foreground one frees it now
198    if (!e.run_in_background) await $.store.delete(key)
199  }
200}
201
202export function register(on: any, options: { language?: string } = {}) {
203  on('session.start', async ($: any, e: any, next: any) => {
204    const started = await next(e)
205    const settings = await $.settings.read().catch(() => ({}))
206    M = MESSAGES[pickLang(options.language, settings?.language)]
207    sid = await $.session.id()
208    await $.command.register({ name: 'para', description: M.cmdDesc })
209    await beat($, false)
210    $.clock.every(BEAT_MS, () => beat($))
211    return started
212  })
213
214  on('turn.start', async ($: any, e: any, next: any) => {
215    const r = await next(e)
216    await beat($, true)
217    return r
218  })
219
220  on('turn.complete', async ($: any, e: any, next: any) => {
221    const r = await next(e)
222    await beat($, false)
223    return r
224  })
225
226  on('session.end', async ($: any, e: any, next: any) => {
227    if (sid) await $.store.delete(`s:${sid}`)
228    return next(e)
229  })
230
231  // A note only the model reads, when the PC is choked
232  on('prompt.submit', async ($: any, e: any, next: any) => {
233    if ((await $.store.get('manage')) === false) return next(e)
234    const l = (await $.store.get('load')) as Load | undefined
235    const lv = level(l)
236    if (lv === 'ok') return next(e)
237    const note = M.note(M.level[lv], l!.cpu, l!.freeGb)
238    return next({ ...e, context: [...(e.context ?? []), note] })
239  })
240
241  on('tool.call', { tool: 'Bash' }, gate)
242  on('tool.call', { tool: 'PowerShell' }, gate)
243
244  on('command.run', { command: 'para' }, async ($: any, e: any) => {
245    const arg = String(e.args ?? '').trim()
246    if (arg === 'off' || arg === 'on') {
247      await $.store.set('manage', arg === 'on')
248      await beat($)
249      return { text: arg === 'on' ? M.on : M.off }
250    }
251    const at = await $.clock.now()
252    const l = await sample($, at)
253    const s = await scan($, at)
254    const own = (await $.store.keys()).filter((k: string) => k.startsWith(`h:${sid}:`)).length
255    const heavy = [...s.heavy.map((h) => h.cmd)]
256    return {
257      text: [
258        line(l, s.chats, s.busy),
259        M.heavyLine(s.heavy.length, own),
260        ...heavy.map((c) => `  - ${c}`),
261        M.rule(TIGHT, OVER),
262        M.pacing((await $.store.get('manage')) !== false),
263      ].join('\n'),
264    }
265  })
266}
267