Development only: plays a scripted turn of real tool calls, without a model, for Control Room screenshots.

Not part of the plugin and never installed. It fills Project Sentinel with a short piece of real work so the status bar and the panel can be captured for the README without a model: the repository's terminal screenshots come from it.
/demo submits a request ("The ISS speed test fails. Fix it, add orbitalPeriod with a test, and document the helpers.") and answers each model step of that turn from a script, with a turn.step hook that yields the step's text and tool calls itself. So the turn is a genuine engine turn: Claude Code runs every tool call in it through Project Sentinel's hooks, its own permission check and the tool itself. In the sample project in project/ the turn records four milestones (with Project Sentinel's milestones tool where it is offered, a cr-test snapshot's included, else TodoWrite), reads and searches the code, runs npm test (which fails), fixes the bug, runs the tests again (they pass), writes a new module with its test, runs npm run lint (there is no lint script, so it fails) and edits the README. Every other turn goes to the model as usual.
Only the words and the token counts are scripted: the context reading climbs from 41% to 51% of the window, and the prompt cache is used as a real turn uses it (the first request finds the earlier turns cached and writes its own tail; each later one reads what the one before sent). Claude Code reports no cost for a step no model answered, so the cost stays $0.00. No login is needed.
| Command | What it does |
|---|---|
/demo | Play the scripted turn, about a second per step |
/demo slow | The same, four seconds per step, to capture the status bar while it runs |
/demo miss | The same, with a model switch halfway through (as /model makes one): from the fifth step the requests report the other model and the cache is rebuilt, so Cache, Cache health and the status bar's rebuilt have something to show |
/demo calls | Replay the same work as bare tool calls outside any turn (no top line, no This turn) |
The words combine: /demo slow miss.
On Windows, with tools/console:
Copy-Item -Recurse -Force tools\demo\project\* C:\path\to\a\trusted\folder\
.\tools\console\console.ps1 launch -Dir C:\path\to\a\trusted\folder -Cols 150 -Lines 48 -Font 'Cascadia Mono' `
-Claude 'claude --allowedTools "Edit Write Bash(npm test) Bash(npm run lint)"' -Also tools\demo
.\tools\console\console.ps1 send -Spec 'text:/demo miss|enter'
The launch adds --plugin-dir plugins/project-sentinel (or the -Plugin snapshot) itself, then one for each -Also folder. Load the demo with -Also, not with a --plugin-dir of its own in -Claude: plugins loaded later sit beneath the ones before them, and the demo's turn.step hook must sit beneath Project Sentinel's, or Project Sentinel never sees the scripted steps (no cache figures, no model switch). A turn edits the sample files, so copy project/ back before the next one, and empty the folder afterwards.
hooks/register.ts 261 lines1/**
2 * Development only: plays a short, scripted piece of work so Control Room's
3 * status bar, Activity and run progress can be captured for screenshots
4 * without a model (see README.md).
5 *
6 * `/demo` submits a request and answers each of the turn's model steps from
7 * a script, so the turn is a genuine engine turn: Claude Code runs every tool
8 * call in it through Control Room's hooks, its own permission check and the
9 * tool itself. The tests really fail and pass, the edits really land in the
10 * sample project. Only the model's words and its token counts are scripted.
11 *
12 * `/demo calls` replays the same work as bare tool calls, outside any turn.
13 * `/demo slow` paces the scripted turn, for a capture while it runs.
14 * `/demo miss` reports a model change halfway through, which rebuilds the
15 * prompt cache: Control Room's Cache reading and Cache health show it.
16 */
17
18import type { Register, ToolCallArgs, TurnStepChunk, TurnStepResult } from 'claude-code'
19
20/** Control Room's milestones tool, offered where Claude Code has no task list. */
21/** Project Sentinel's milestones tool (or a test snapshot's, `cr-test`), found among the offered tools when /demo runs. */
22let milestonesTool = 'mcp__project-sentinel__milestones'
23
24/** The request the scripted turn answers: the run's objective. */
25const REQUEST = 'The ISS speed test fails. Fix it, add orbitalPeriod with a test, and document the helpers.'
26
27/** The milestones, as Claude would list them: subject and its "doing" form. */
28const MILESTONES: readonly (readonly [string, string])[] = [
29 ['Reproduce the failing ISS test', 'Reproducing the failing ISS test'],
30 ['Fix orbitalSpeed', 'Fixing orbitalSpeed'],
31 ['Add orbitalPeriod with a test', 'Adding orbitalPeriod'],
32 ['Document the helpers', 'Documenting the helpers'],
33]
34
35const PERIOD = `// The time one circular orbit takes.
36const G = 6.674e-11
37
38/** Orbital period (s) at radius r (m) around a body of mass m (kg). */
39export function orbitalPeriod(mass, radius) {
40 return 2 * Math.PI * Math.sqrt(radius ** 3 / (G * mass))
41}
42`
43
44const PERIOD_TEST = `import { test } from 'node:test'
45import assert from 'node:assert/strict'
46import { orbitalPeriod } from '../src/period.js'
47
48test('the ISS goes round in about 92 minutes', () => {
49 const minutes = orbitalPeriod(5.972e24, 6_371_000 + 420_000) / 60
50 assert.ok(Math.abs(minutes - 92.7) < 1, \`got \${minutes}\`)
51})
52`
53
54type State = 'pending' | 'in_progress' | 'completed'
55type Call = { name: string; input: Record<string, unknown> }
56type Step = { say?: string; calls: Call[] }
57
58/** The milestones tool's whole list for these states, as Claude sends it. */
59function milestonesCall(states: readonly State[], objective?: string): Call {
60 const milestones = MILESTONES.map(([title, doing], i) => {
61 const status = states[i] ?? 'pending'
62 return status === 'in_progress' ? { title, status, doing } : { title, status }
63 })
64 return { name: milestonesTool, input: objective === undefined ? { milestones } : { objective, milestones } }
65}
66
67/** TodoWrite's whole list for these states, where Claude Code offers it instead. */
68function todosCall(states: readonly State[]): Call {
69 return { name: 'TodoWrite', input: { todos: MILESTONES.map(([content, activeForm], i) => ({ content, activeForm, status: states[i] ?? 'pending' })) } }
70}
71
72/** The scripted turn, one entry per model step; the last one answers. */
73function script(root: string, via: 'milestones' | 'todos' | null): Step[] {
74 const at = (path: string) => `${root}/${path}`
75 const plan = (states: readonly State[], objective?: string): Call[] =>
76 via === 'milestones' ? [milestonesCall(states, objective)] : via === 'todos' ? [todosCall(states)] : []
77 const bash = (command: string, description: string): Call => ({ name: 'Bash', input: { command, description } })
78 return [
79 {
80 say: "I'll reproduce the failing test first.",
81 calls: [
82 ...plan(['in_progress', 'pending', 'pending', 'pending'], 'Fix the ISS test, add orbitalPeriod, document the helpers'),
83 { name: 'Read', input: { file_path: at('src/orbit.js') } },
84 { name: 'Read', input: { file_path: at('test/orbit.test.js') } },
85 { name: 'Grep', input: { pattern: 'orbitalSpeed', path: root } },
86 ],
87 },
88 { calls: [bash('npm test', 'Run the tests')] },
89 {
90 say: '`orbitalSpeed` multiplies by 2, which doubles every result. Removing the factor.',
91 calls: [
92 ...plan(['completed', 'in_progress', 'pending', 'pending']),
93 { name: 'Edit', input: { file_path: at('src/orbit.js'), old_string: 'Math.sqrt((G * mass) / radius) * 2', new_string: 'Math.sqrt((G * mass) / radius)' } },
94 ],
95 },
96 { calls: [bash('npm test', 'Run the tests')] },
97 {
98 say: 'Fixed. Now `orbitalPeriod`, with a test.',
99 calls: [
100 ...plan(['completed', 'completed', 'in_progress', 'pending']),
101 { name: 'Write', input: { file_path: at('src/period.js'), content: PERIOD } },
102 { name: 'Write', input: { file_path: at('test/period.test.js'), content: PERIOD_TEST } },
103 ],
104 },
105 { calls: [bash('npm test', 'Run the tests'), bash('npm run lint', 'Lint the sources')] },
106 {
107 say: 'The project has no lint script, so I will say so rather than invent one. Documenting the helpers.',
108 calls: [
109 ...plan(['completed', 'completed', 'completed', 'in_progress']),
110 { name: 'Edit', input: { file_path: at('README.md'), old_string: 'Small helpers for circular orbits.', new_string: 'Small helpers for circular orbits: `orbitalSpeed` and `orbitalPeriod`.' } },
111 ],
112 },
113 { calls: plan(['completed', 'completed', 'completed', 'completed']) },
114 {
115 say: [
116 'Done.',
117 '',
118 '- `orbitalSpeed` no longer doubles its result; the ISS test passes.',
119 '- `orbitalPeriod` is new, with a test (the ISS goes round in about 92 minutes).',
120 '- The README names both helpers.',
121 '',
122 'Tests pass, 2 of 2. `npm run lint` fails because the project has no lint script; say if you want one.',
123 ].join('\n'),
124 calls: [],
125 },
126 ]
127}
128
129/** The share of the context window the scripted turn reports at each step: a session well under way. */
130const contextAt = (index: number) => 0.41 + 0.012 * index
131
132/** With `/demo miss`, the step from which the requests report another model (a switch, as `/model` makes one). */
133const MISS_AT = 4
134
135/** The other model of a scripted switch: the same generation, the next family down. */
136const otherModel = (model: string) => (model.includes('opus') ? model.replace('opus', 'sonnet') : model.includes('sonnet') ? model.replace('sonnet', 'opus') : 'claude-sonnet-5-5')
137
138export const register: Register = on => {
139 /** The scripted turn under way, and its steps, once `/demo` submitted its request. */
140 let pending: { steps: Step[]; paceMs: number; isMiss: boolean } | null = null
141 let active: { turnId: string; steps: Step[]; paceMs: number; isMiss: boolean } | null = null
142
143 on('session.start', async ($, e, next) => {
144 const started = await next(e)
145 await $.command.register({ name: 'demo', description: 'Play the scripted demo turn (development only)', argumentHint: '[slow|calls|miss]' })
146 return started
147 })
148
149 on('turn.start', ($, e, next) => {
150 if (pending !== null && e.text.includes(REQUEST)) {
151 active = { turnId: e.turnId, ...pending }
152 pending = null
153 }
154 return next(e)
155 })
156
157 on('turn.complete', ($, e, next) => {
158 if (active !== null && e.turnId === active.turnId && e.agentId === undefined) active = null
159 return next(e)
160 })
161
162 // Answers the scripted turn's model steps; every other step goes to the model.
163 on('turn.step', async function* ($, e, next) {
164 const turn = active
165 if (turn === null || e.turnId !== turn.turnId || e.agentId !== undefined) return yield* next(e)
166 const step = turn.steps[e.index] ?? { say: 'Done.', calls: [] }
167 if (turn.paceMs > 0) await $.clock.sleep(turn.paceMs, { signal: next.signal })
168 let block = 0
169 if (step.say !== undefined) {
170 const chunk: TurnStepChunk = { kind: 'text', index: block, text: step.say }
171 yield chunk
172 block += 1
173 }
174 for (const [i, call] of step.calls.entries()) {
175 yield { kind: 'tool', index: block, id: `toolu_demo_${e.index}_${i}`, name: call.name }
176 yield { kind: 'input', index: block, json: JSON.stringify(call.input) }
177 block += 1
178 }
179 const window = (await $.session.usage()).context?.window ?? 200_000
180 const prompt = Math.round(window * contextAt(e.index))
181 // The cache as a real turn uses it: the first request finds the earlier turns cached and writes
182 // its own tail; each later one reads what the one before sent. A scripted switch rebuilds it all.
183 const before = e.index === 0 ? Math.round(window * 0.38) : Math.round(window * contextAt(e.index - 1))
184 const isSwitch = turn.isMiss && e.index === MISS_AT
185 const read = isSwitch ? 0 : before
186 const model = turn.isMiss && e.index >= MISS_AT ? otherModel(e.model) : e.model
187 const usage = { model, input_tokens: 1_800, cache_read_input_tokens: read, cache_creation_input_tokens: Math.max(0, prompt - read - 1_800), output_tokens: 240 + 60 * step.calls.length }
188 const stopReason = step.calls.length > 0 ? ('tool_use' as const) : ('end_turn' as const)
189 yield { kind: 'stop', stopReason, usage }
190 const result: TurnStepResult = {
191 turnId: e.turnId,
192 index: e.index,
193 answer: step.say ?? '',
194 toolUses: step.calls.map(c => ({ name: c.name, input: c.input })),
195 stopReason,
196 usage,
197 }
198 return result
199 })
200
201 on('command.run', { command: 'demo' }, async ($, e) => {
202 const root = (await $.session.root()).replace(/[\\/]+$/, '')
203 const names = new Set((await $.tool.list()).map(t => t.name))
204 milestonesTool = [...names].find(n => /^mcp__(project-sentinel|cr-test)__milestones$/.test(n)) ?? milestonesTool
205 const args = String(e.args ?? '').trim().toLowerCase()
206 if (args === 'calls') return replayCalls()
207 const via = names.has(milestonesTool) ? 'milestones' : names.has('TodoWrite') ? 'todos' : null
208 const words = args.split(/\s+/)
209 pending = { steps: script(root, via), paceMs: words.includes('slow') ? 4_000 : 900, isMiss: words.includes('miss') }
210 $.clock.after(50, () => $.prompt.submit({ text: REQUEST }))
211 return { text: 'Playing the scripted turn: watch the status bar, then open Control Room (/cr).' }
212
213 async function replayCalls() {
214 const at = (path: string) => `${root}/${path}`
215 const shell = (command: string, description: string) => $.tool.call({ tool: 'Bash', command, description }).catch(() => undefined)
216 // The task list as Claude would keep it here: Control Room's milestones
217 // tool where it is offered, else the Task tools, else TodoWrite.
218 const via = names.has(milestonesTool) ? 'milestones' : names.has('TaskCreate') ? 'tasks' : 'todos'
219 const ids: string[] = []
220 if (via === 'tasks') {
221 for (const [subject, activeForm] of MILESTONES) {
222 const created = await $.tool.call({ tool: 'TaskCreate', subject, description: subject, activeForm })
223 const task = (created as { result?: { task?: { id?: unknown } } }).result?.task
224 ids.push(typeof task?.id === 'string' ? task.id : '')
225 }
226 }
227 const plan = async (states: readonly State[]) => {
228 if (via === 'milestones') {
229 // Registered at run time, so the generated tool types do not name it.
230 await $.tool.call({ tool: milestonesTool, ...milestonesCall(states).input } as unknown as ToolCallArgs)
231 } else if (via === 'todos') {
232 await $.tool.call({ tool: 'TodoWrite', ...todosCall(states).input } as unknown as ToolCallArgs)
233 } else {
234 for (const [i, s] of states.entries()) if (s !== 'pending' && ids[i] !== '') await $.tool.call({ tool: 'TaskUpdate', taskId: ids[i] ?? '', status: s })
235 }
236 }
237
238 await plan(['in_progress', 'pending', 'pending', 'pending'])
239 await $.tool.call({ tool: 'Read', file_path: at('src/orbit.js') })
240 await $.tool.call({ tool: 'Read', file_path: at('test/orbit.test.js') })
241 await $.tool.call({ tool: 'Grep', pattern: 'orbitalSpeed', path: root })
242 await shell('npm test', 'Run the tests')
243
244 await plan(['completed', 'in_progress', 'pending', 'pending'])
245 await $.tool.call({ tool: 'Edit', file_path: at('src/orbit.js'), old_string: 'Math.sqrt((G * mass) / radius) * 2', new_string: 'Math.sqrt((G * mass) / radius)' })
246 await shell('npm test', 'Run the tests')
247
248 await plan(['completed', 'completed', 'in_progress', 'pending'])
249 await $.tool.call({ tool: 'Write', file_path: at('src/period.js'), content: PERIOD })
250 await $.tool.call({ tool: 'Write', file_path: at('test/period.test.js'), content: PERIOD_TEST })
251 await shell('npm test', 'Run the tests')
252 await shell('npm run lint', 'Lint the sources')
253
254 await plan(['completed', 'completed', 'completed', 'in_progress'])
255 await $.tool.call({ tool: 'Edit', file_path: at('README.md'), old_string: 'Small helpers for circular orbits.', new_string: 'Small helpers for circular orbits: `orbitalSpeed` and `orbitalPeriod`.' })
256 await $.tool.call({ tool: 'Write', file_path: at('NEXT_SESSION_PROMPT.md'), content: '# Next session\n\nDocument the helpers, then add a lint script.\n' })
257 return { text: 'Demo replayed: open Control Room (/cr) and look at Activity.' }
258 }
259 })
260}
261