SLOPSHOPPER

project-sentinel-demo

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

newcommandtimer
★ 51v0.0.0MITupdated 2026-10-08Arjun0014/project-sentinel/tools/demo
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · project-sentinel-demo
› 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 › /demo ⎿ project-sentinel-demo: Playing the scripted turn: watch the status bar, then open Control Room (/cr). ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Demo driver (development only)

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.

CommandWhat it does
/demoPlay the scripted turn, about a second per step
/demo slowThe same, four seconds per step, to capture the status bar while it runs
/demo missThe 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 callsReplay 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.

Source 1 files
hooks/register.ts 261 lines
1/**
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