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…

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.
/para: the state and the rule. /para off stops the pacing, /para on brings it backThe 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.
/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.
Mods are not sandboxed, so here it is before you install. The code is one file, hooks/register.tsx.
$.http is not used). No environment variables, no filespowershell -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)language setting to pick the languageBash and PowerShell tool calls and may hold back heavy commandsA 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 に入れて使うことはできますが、複製・改変・再配布は許可していません)
hooks/register.tsx 267 lines1// 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