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

<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>
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).
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./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-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.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.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.~/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.Localix-macos-arm64.dmg from the latest release, signed and notarizedEach release lists checksums in SHA256SUMS.txt.
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.
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.
hooks/register.js 201 lines1import { 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}
201hooks/shape.js 101 lines1/**
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