SLOPSHOPPER

cc-parity-probe

Records the shapes of the events and mods API answers it sees, for Localix's parity check

newpanebandspinnerrowsguard
★ 19v0.1.0MITupdated 2026-10-26rsaryev/localix/scripts/cc-parity/probe
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-parity-probe
› fix the failing auth test and add an audit log call ● cc-parity-probe: cc-parity probe: 36 shapes recorded ⏺ 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 › /probe ⎿ cc-parity-probe: cc-parity probe: 37 shapes recorded ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<img src="assets/logo.svg" width="72" height="72" alt="Localix">

<h1 align="center">Localix</h1>

<strong>The Agentic Development Environment</strong>

A team of AI agents works with you in one desktop app: your editor, terminals and browser. Any model, or run one on your own computer.

<a href="https://github.com/rsaryev/localix/releases/latest">Download</a> · <a href="docs/README.md">Documentation</a> · <a href="#run-from-source">Run from source</a>

<a href="https://localix.ai"> <img src="docs/assets/films/demo.png" alt="Watch the Localix demo: a /workflows prompt fills the board, agents build and test a 2048 game in the browser, and a comment gets the cells resized" width="880"> </a>

<table> <tr> <td width="33%" valign="top"> <strong>Agent teams</strong><br> Specialists share one chat; a clear request goes straight to the right one.<br> <a href="docs/features/group-routing.md">Group routing →</a> </td> <td width="33%" valign="top"> <strong>A fast Browser</strong><br> Agents drive any page step by step and check the result.<br> <a href="docs/features/workspace.md#the-browser">The Browser →</a> </td> <td width="33%" valign="top"> <strong>Decision models</strong><br> Small, cheap models make the quick calls across the app.<br> <a href="docs/features/decision-models.md">Decision models →</a> </td> </tr> <tr> <td valign="top"> <strong>Mini apps, built by the agent</strong><br> Describe one with <code>/mini-app create</code>: a meal log from photos, a dashboard, a game, wired to the agent, models and embeddings.<br> <a href="docs/extending/build-with-the-agent.md">Build a mini app →</a> </td> <td valign="top"> <strong>Your coding agents</strong><br> Claude Code, Codex, Gemini CLI and more, linked to your chats.<br> <a href="docs/features/external-agents.md">External agents →</a> </td> <td valign="top"> <strong>Any model</strong><br> Sign in or bring a key, or run models on this computer.<br> <a href="docs/features/providers.md">Providers →</a> · <a href="docs/features/local-models.md">Local models →</a> </td> </tr> <tr> <td colspan="3" valign="top"> <strong>Mini apps as Mac apps</strong><br> Install a mini app as its own app: a Dock icon, a window of its own, and the agent beside it.<br> <a href="docs/features/mini-app-mac-apps.md">Mini apps as Mac apps →</a> </td> </tr> </table>

Plugins and mini apps

Two separate kinds of extension that share nothing. A plugin is the Claude Code format — skills, commands, agents, hooks, mods, MCP and LSP servers — and the same folder loads in Claude Code and in Localix; manage plugins with /plugin. A mini app is Localix's own format: screens, a backend, and agent tools over Localix's own APIs; manage mini apps with /mini-app and in the Store (mini apps).

  • Compatible with Claude Code. Plugins, skills, slash commands, agents, hooks, mods, MCP and language servers, output styles, CLAUDE.md and your ~/.claude settings load the way Claude Code loads them, checked against Claude Code 2.1.310. What differs, and why, is listed in Claude Code compatibility.
  • Claude Code plugins work as they are. /plugin install owner/repo reads .claude-plugin/plugin.json in place, with Claude Code's rules for commands, skills, hooks and MCP servers; what Localix does not run yet is reported, never dropped silently. See Claude Code plugins.
  • Mini apps, built by the agent. Run /mini-app create <what it should do>. The agent writes the interface from the app's own components and the tools that go with it; each build is checked — style, types, builds and tests — and replaces the working one only when every check passes. See build with the agent.
  • Any app, part of Localix. A calorie counter needs only a log_meal tool and a screen: attach a meal photo and, with a model that reads images, the chat agent logs it through that tool. A deployment dashboard reads your cluster through its own tools; a research board ranks notes with the embedding model; a game's opponent is a decision model. The bundled DevOps, Database, Trading and Kanban workbenches are mini apps on the same contract.
  • Localix's APIs. A mini app's backend can start an agent turn (ctx.host.wake), ask a decision model typed questions (ctx.host.decisions), index and search its own items by meaning (ctx.host.semantic), resolve vault secrets, keep data per chat or per project, follow its own changes and push live updates to its screen. See what you can build.
  • Its own Mac app. Install App as App, from the app's tab, its rail button or the command palette, makes ~/Applications/Localix Apps/<App>.app: its own Dock icon, menu bar, Cmd+Tab entry and window, found by Spotlight and Launchpad. It opens the app's screen in a project of its own, with the agent beside it and its conversations as the app's history. Localix stays the host and starts in the background if it is not running. macOS only. See mini apps as Mac apps.

On this computer

  • Local models. Settings → Models picks Gemma 4 at the size your machine runs well, with a Fastest–Smartest slider, and adds any GGUF model by its Hugging Face id. Nothing you send them leaves the computer. See local models.
  • Search by meaning. On by default: an embedding model downloads in the background, and search then finds chats, messages, skills, plugins, MCP servers and agents by what they mean, in any language. See semantic search.

Download

  • macOS, Apple silicon: Localix-macos-arm64.dmg from the latest release, signed and notarized
  • macOS Intel, Linux, Windows: no installer yet, run from source

Each release lists checksums in SHA256SUMS.txt.

Run from source

For the desktop app, install Bun 1.4.4, a Rust toolchain, and your platform's Tauri prerequisites.

git clone https://github.com/rsaryev/localix.git
cd localix
bun install --frozen-lockfile
bun run dev

Connect a provider through the model picker — or download a model in Settings → Models to run one on this computer — choose a model, and start a conversation.

To use the browser workspace instead of the desktop shell:

bun run dev:web

Open http://localhost:5050 after the servers start; the API listens on 127.0.0.1:5051. This uses a separate, git-ignored database at .data/dev/localix.db.

See installation for platform requirements and local builds, and troubleshooting for startup problems.

Contributing

Localix is in beta. Start with CONTRIBUTING.md and the code map. Report vulnerabilities as SECURITY.md describes; participation follows the Code of Conduct. Localix is released under the MIT License.

Source 2 files
hooks/register.js 201 lines
1import { describe, mergeShape, REJECTED, sameShape } from './shape.js'
2
3/**
4 * The cc-parity probe: a Claude Code mod that records what the host it runs in hands a mod — each
5 * event's input `e`, what `next(e)` resolves to, and the answers of cheap `$` calls — as shape
6 * descriptors (`shape.js`), never values. Entries are `{ kind, name, shape }`, one per name, shapes
7 * merged, written with `$.fs.write` as JSON to the path in the `output` option or the
8 * `CC_PARITY_OUT` environment variable; an existing file is merged into, so the file is stable
9 * across runs. The same module runs in Claude Code (`claude --plugin-dir`) and in Localix's mod
10 * host (`scripts/cc-parity/localix`), so their recordings compare one to one.
11 *
12 * Model calls (`$.model.*`) spend the person's usage: they run only when `CC_PARITY_ALLOW_MODEL`
13 * is `1` or the `allowModel` option is true. Written to Claude Code's static-analysis rules: every
14 * `$` call in full, `$` passed only to functions declared at the top of this file.
15 */
16
17const PANE = 'cc-parity-probe'
18const STORE_KEY = 'cc-parity-probe'
19const TINY = 'Reply with the single word: ok'
20
21const entries = new Map()
22let config = {}
23let output
24let writing = Promise.resolve()
25let merged = false
26let surveyed = false
27
28async function outputPath($) {
29  if (output !== undefined) return output
30  const option = typeof config.output === 'string' ? config.output : ''
31  output = option || (await $.env.get('CC_PARITY_OUT')) || ''
32  return output
33}
34
35async function modelAllowed($) {
36  if (config.allowModel === true) return true
37  return (await $.env.get('CC_PARITY_ALLOW_MODEL')) === '1'
38}
39
40/** Merge one observation in, and write the file when it changed what is known. */
41async function record($, kind, name, value, depth) {
42  try {
43    const shape = value === REJECTED ? REJECTED : describe(value, depth)
44    const key = kind + ':' + name
45    const known = entries.get(key)
46    const next = known ? mergeShape(known.shape, shape) : shape
47    if (known && sameShape(known.shape, next)) return
48    entries.set(key, { kind, name, shape: next })
49    await save($)
50  } catch {
51    // Recording never changes what the host does.
52  }
53}
54
55/** Writes are queued, so concurrent hooks never interleave a file. */
56function save($) {
57  writing = writing.then(() => write($)).catch(() => {})
58  return writing
59}
60
61async function write($) {
62  const path = await outputPath($)
63  if (!path) return
64  if (!merged) {
65    merged = true
66    if (await $.fs.exists(path)) mergeFile(await $.fs.read(path))
67  }
68  const list = [...entries.values()].sort((a, b) =>
69    (a.kind + ':' + a.name).localeCompare(b.kind + ':' + b.name),
70  )
71  await $.fs.write(path, JSON.stringify({ probe: 'cc-parity', entries: list }, null, 2) + '\n')
72}
73
74function mergeFile(text) {
75  try {
76    for (const entry of JSON.parse(text).entries ?? []) {
77      const key = entry.kind + ':' + entry.name
78      const known = entries.get(key)
79      entries.set(key, known ? { ...known, shape: mergeShape(entry.shape, known.shape) } : entry)
80    }
81  } catch {
82    // A file that is not the probe's is overwritten.
83  }
84}
85
86/** One `$` call's answer, or that it rejected. */
87async function call($, name, run, depth) {
88  let value
89  try {
90    value = await run()
91  } catch {
92    value = REJECTED
93  }
94  await record($, 'call', name, value, depth)
95}
96
97/** The cheap `$` calls, once a session: after the first turn, or on `/probe`. */
98async function survey($) {
99  if (surveyed) return
100  surveyed = true
101  await call($, 'session.cwd', () => $.session.cwd())
102  await call($, 'session.id', () => $.session.id())
103  await call($, 'session.model', () => $.session.model())
104  await call($, 'session.usage', () => $.session.usage())
105  await call($, 'session.messages', () => $.session.messages(), 4)
106  await call($, 'session.turns', () => $.session.turns())
107  await call($, 'session.version', () => $.session.version())
108  await call($, 'session.surfaces', () => $.session.surfaces())
109  await call($, 'command.list', () => $.command.list(), 3)
110  await call($, 'tool.list', () => $.tool.list(), 2)
111  await call($, 'prompt.suggest', () => $.prompt.suggest({ text: 'cc-parity probe' }))
112  await call($, 'prompt.fill', () => $.prompt.fill({ text: '', mode: 'append' }))
113  await call($, 'ui.open', () => $.ui.open({ id: PANE, title: 'cc-parity probe' }))
114  // The pane stays open, so the host draws it (`ui.render:Pane`).
115  await call($, 'ui.panes', () => $.ui.panes())
116  await call($, 'fs.exists', () => $.fs.exists('.'))
117  await call($, 'clock.now', () => $.clock.now())
118  await call($, 'store.set', () => $.store.set(STORE_KEY, { seen: true }))
119  await call($, 'store.get', () => $.store.get(STORE_KEY))
120  // The settings' own keys are the person's: only that an object answers is recorded.
121  await call($, 'settings.read', () => $.settings.read(), 0)
122  await call($, 'env.get', () => $.env.get('HOME'))
123  if (await modelAllowed($)) {
124    const model = 'haiku'
125    await call($, 'model.complete', () => $.model.complete({ model, prompt: TINY, maxTokens: 16 }))
126    await call($, 'model.fork', () => $.model.fork({ prompt: TINY }))
127    await call($, 'model.classify', () =>
128      $.model.classify('The sky is blue.', ['color', 'number'], { model }),
129    )
130  }
131  $.ui.log('cc-parity probe: ' + entries.size + ' shapes recorded')
132}
133
134export function register(on, options) {
135  config = options ?? {}
136
137  on('session.start', async ($, e, next) => {
138    await record($, 'event', 'session.start', e)
139    await call($, 'command.register', () =>
140      $.command.register({ name: 'probe', description: 'Record the cc-parity probe shapes' }),
141    )
142    return next(e)
143  })
144
145  on('session.end', async ($, e, next) => {
146    await record($, 'event', 'session.end', e)
147    return next(e)
148  })
149
150  on('prompt.submit', async ($, e, next) => {
151    await record($, 'event', 'prompt.submit', e)
152    const result = await next(e)
153    await record($, 'result', 'prompt.submit', result)
154    return result
155  })
156
157  on('turn.start', async ($, e, next) => {
158    await record($, 'event', 'turn.start', e)
159    return next(e)
160  })
161
162  on('turn.step', async function* ($, e, next) {
163    await record($, 'event', 'turn.step', e)
164    const result = yield* next(e)
165    await record($, 'result', 'turn.step', result, 3)
166    return result
167  })
168
169  on('turn.complete', async ($, e, next) => {
170    await record($, 'event', 'turn.complete', e)
171    await survey($)
172    return next(e)
173  })
174
175  on('tool.call', async ($, e, next) => {
176    await record($, 'event', 'tool.call:' + e.tool, e)
177    const result = await next(e)
178    await record($, 'result', 'tool.call:' + e.tool, result, 2)
179    return result
180  })
181
182  on('tool.check', async ($, e, next) => {
183    await record($, 'event', 'tool.check', e, 2)
184    const verdict = await next(e)
185    await record($, 'result', 'tool.check', verdict)
186    return verdict
187  })
188
189  on('command.run', { command: 'probe' }, async ($, e) => {
190    await record($, 'event', 'command.run', e)
191    await survey($)
192    return { text: 'cc-parity probe: ' + entries.size + ' shapes recorded' }
193  })
194
195  // Every site drawn: its component, surface and the names and kinds of its props.
196  on('ui.render', async ($, e, next) => {
197    await record($, 'event', 'ui.render:' + e.component, e, 2)
198    return next(e)
199  })
200}
201
hooks/shape.js 101 lines
1/**
2 * Shape descriptors: what a value looks like, never what it holds. A primitive is its kind
3 * (`'string'`, `'number'`, `'boolean'`, `'null'`, `'undefined'`, `'function'`); an object is its
4 * keys, sorted, each with its own shape; an array is `[]` when empty, else `[shape]`, the shape of
5 * its elements merged. Values that differ in kind merge into `{ $oneOf: [...] }`, and a key one
6 * observation has and another lacks is kept as `'key?'`. Past `depth` levels an object or array is
7 * `'object'` or `'array'`, so a record keyed by data (file paths, settings) is never spelled out.
8 * No string, number or other value of the input ever reaches a descriptor.
9 */
10
11export const REJECTED = '<rejected>'
12const ONE_OF = '$oneOf'
13
14/** Text order by code unit, the same everywhere. */
15export const byText = (a, b) => (a < b ? -1 : a > b ? 1 : 0)
16
17const isPlain = (shape) =>
18  shape !== null && typeof shape === 'object' && !Array.isArray(shape) && !(ONE_OF in shape)
19
20/** The shape of `value`, objects expanded `depth` levels deep. */
21export function describe(value, depth = 6) {
22  if (value === null) return 'null'
23  if (Array.isArray(value)) {
24    if (depth <= 0) return 'array'
25    if (value.length === 0) return []
26    return [value.map((item) => describe(item, depth - 1)).reduce(mergeShape)]
27  }
28  if (typeof value !== 'object') return typeof value
29  if (depth <= 0) return 'object'
30  const shape = {}
31  for (const key of Object.keys(value).sort()) shape[key] = describe(value[key], depth - 1)
32  return shape
33}
34
35const bare = (key) => (key.endsWith('?') ? key.slice(0, -1) : key)
36
37/** Two observations of one thing as one shape. */
38export function mergeShape(a, b) {
39  if (sameShape(a, b)) return a
40  if (Array.isArray(a) && Array.isArray(b)) {
41    if (a.length === 0) return b
42    if (b.length === 0) return a
43    return [mergeShape(a[0], b[0])]
44  }
45  if (isPlain(a) && isPlain(b)) return mergeObjects(a, b)
46  return oneOf([...members(a), ...members(b)])
47}
48
49function mergeObjects(a, b) {
50  const keys = new Map()
51  for (const [source, other] of [
52    [a, b],
53    [b, a],
54  ])
55    for (const key of Object.keys(source)) {
56      const name = bare(key)
57      const optional = key.endsWith('?') || !(name in other || `${name}?` in other)
58      const shape = source[key]
59      const seen = keys.get(name)
60      keys.set(name, {
61        optional: (seen?.optional ?? false) || optional,
62        shape: seen ? mergeShape(seen.shape, shape) : shape,
63      })
64    }
65  const merged = {}
66  for (const name of [...keys.keys()].sort(byText))
67    merged[keys.get(name).optional ? `${name}?` : name] = keys.get(name).shape
68  return merged
69}
70
71const members = (shape) =>
72  shape && typeof shape === 'object' && ONE_OF in shape ? shape[ONE_OF] : [shape]
73
74/** A union: one object member and one array member at most, each merged, sorted. */
75function oneOf(list) {
76  let object
77  let array
78  const rest = []
79  for (const shape of list) {
80    if (isPlain(shape)) object = object ? mergeObjects(object, shape) : shape
81    else if (Array.isArray(shape)) array = array ? mergeShape(array, shape) : shape
82    else if (!rest.includes(shape)) rest.push(shape)
83  }
84  const all = [...rest.sort(byText), ...(array ? [array] : []), ...(object ? [object] : [])]
85  return all.length === 1 ? all[0] : { [ONE_OF]: all }
86}
87
88/** Whether two shapes are the same, key order aside. */
89export function sameShape(a, b) {
90  return canonical(a) === canonical(b)
91}
92
93export function canonical(shape) {
94  if (shape === null || typeof shape !== 'object') return JSON.stringify(shape)
95  if (Array.isArray(shape)) return `[${shape.map(canonical).join(',')}]`
96  return `{${Object.keys(shape)
97    .sort()
98    .map((key) => `${JSON.stringify(key)}:${canonical(shape[key])}`)
99    .join(',')}}`
100}
101