SLOPSHOPPER

nat-embedded

nat's own mod, loaded into every Claude Code session nat launches with --plugin-dir and nowhere else.

newspinnerguardpromptprocesstimer
v0.0.0no licenseupdated 2026-10-09craigmjohnston/nat/mods/embedded
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · nat-embedded
› 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. ✻ Working… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

nat-embedded

nat's own Claude Code mod. nat loads it into every agent session it launches with claude --plugin-dir, for that one session only; it is never installed and never reaches a session nat did not start. Nothing here is meant to be loaded by hand.

Tested with Claude Code 2.1.294.

  • hooks/register.ts — the hooks, which quiet the pane's chrome by rewriting Claude Code's own render-site props: the prompt hint (? for shortcuts, which is also how a load shows in the pane) and the run-in-background pill draw empty, the turn duration line and the notices under the logo draw nothing, and the spinner says Working. The session modes are left alone. They also set the pane's waiting flag (nat agent-waiting / nat agent-working) while an AskUserQuestion dialog, a permission prompt or an MCP elicitation waits on the user, or a turn ended on an error or a refusal; a plain finished turn marks nothing. And they deliver what nat sends the session: from session.start, once a second, each file in the inbox NAT_INBOX names, in name order, is removed and then submitted with $.prompt.submit({ text, asUser: true }) — a turn of its own once idle, no composer involved. And they record a resume: a prompt the user typed (origin.kind composer or bridge) on a session with NAT_SLICE and NAT_PROJECT set runs nat slice-resume with the prompt on stdin before it goes on. And they hand the session its brief: prompt.context appends the file NAT_BRIEF names as a natBrief context block, which the model reads and the pane never draws; nat starts the session on one opening line pointing at it. Checked live on 2.1.294: hidden in the pane and under ctrl+o, re-read from the file on /compact, kept as recorded (not re-read) on claude --resume. A session whose mod never loads (an older Claude Code) has the opening line alone and asks what to work.
  • types/index.d.ts — the $.state contract: the wait last written, so a hot reload neither forgets it nor writes the flag again.
  • tests/ — claude plugin test suites.

Check it with ./scripts/mod-check.sh from the repository root (claude plugin validate --strict and claude plugin test; claude on PATH, no sign-in or network). Write against the public mods reference (https://code.claude.com/docs/en/plugins/mods/reference) and the declarations the installed build lays beside a loaded mod (.claude-plugin/types/claude-code/index.d.ts, gitignored with the tsconfig.json beside it), never one build's quirks.

The contract — how nat embeds, writes and loads this folder — is docs/design/embedded-mod/README.md.

Source 2 files
hooks/register.ts 251 lines
1import { atom, update } from 'claude-code'
2import type { EngineInterface, Register, StateDollar } from 'claude-code'
3
4import type { Wait } from '../types'
5
6type Engine = StateDollar & Pick<EngineInterface, 'process' | 'ui'>
7type InboxEngine = Pick<EngineInterface, 'clock' | 'fs' | 'process' | 'prompt' | 'ui'>
8type ResumeEngine = Pick<EngineInterface, 'env' | 'process' | 'ui'>
9
10// What the mod last wrote on the pane, held by the host so a hot reload (a
11// fresh module) neither forgets a wait it marked nor writes the flag again.
12const wait = atom({ plugin: 'nat-embedded', key: 'wait' } as const, null)
13
14// Sets the pane's waiting flag to `to` — what the agent's own `nat
15// agent-waiting` / `nat agent-working` set — only where it changes; a wait
16// for one thing becoming a wait for another rewrites nothing. `from` limits a
17// clear to the waits it names. The session carries nat's PATH and the pane's
18// TMUX_PANE, which is how the command finds the pane. Nothing here throws: a
19// failure goes to the debug log, never the transcript, and never costs the
20// hook calling it — the flag is a hint, not the work.
21async function mark($: Engine, to: Wait | null, from?: readonly Wait[]): Promise<void> {
22  try {
23    let was: Wait | null = null
24    const now = await update($, wait, value => {
25      was = value
26      return to !== null || !from || (value !== null && from.includes(value)) ? to : value
27    })
28    if ((was === null) === (now === null)) return
29    const command = now === null ? 'agent-working' : 'agent-waiting'
30    const { exitCode, stderr } = await $.process.run(['nat', command], { timeoutMs: 10_000 })
31    if (exitCode !== 0) $.ui.log(`nat ${command} exited ${exitCode}: ${stderr.trim()}`, { to: 'debug' })
32  } catch (err) {
33    $.ui.log(`waiting flag (${to ?? 'working'}) not written: ${String(err)}`, { to: 'debug' })
34  }
35}
36
37// One prompt nat sent, as `nat agent-send` (and every other sender) names it:
38// the send's Unix time in nanoseconds, so name order is send order. A temp
39// file still being written has another name and is never read.
40const inboxFile = /^\d+\.md$/
41
42// Delivers what nat left in this session's inbox (`NAT_INBOX`, set by nat on
43// the launch) as the user's own prompts, each a turn of its own once the
44// session is idle — no composer, so no dialog, permission prompt or draft in
45// the pane can take it. Each file is removed before it is submitted, and only
46// a removal that worked submits: a file nat took back after giving up on the
47// mod (and pasted instead) is never sent twice. `busy` keeps a tick from
48// starting while the last is still at it; a reload starts both afresh.
49// Failures go to the debug log; the next tick tries again.
50function pollInbox($: InboxEngine, dir: string): void {
51  let busy = false
52  $.clock.every(1000, async () => {
53    if (busy) return
54    busy = true
55    try {
56      if (!(await $.fs.exists(dir))) return
57      const names = (await $.fs.list(dir))
58        .filter(entry => entry.kind === 'file' && inboxFile.test(entry.name))
59        .map(entry => entry.name)
60        .sort()
61      for (const name of names) {
62        const path = `${dir}/${name}`
63        const text = await $.fs.read(path)
64        const { exitCode } = await $.process.run(['rm', path], { timeoutMs: 10_000 })
65        if (exitCode !== 0) continue
66        // Resolves once the turn starts: not awaited, so the next file is not
67        // held behind a running turn.
68        void $.prompt.submit({ text, asUser: true })
69      }
70    } catch (err) {
71      $.ui.log(`agent inbox not read: ${String(err)}`, { to: 'debug' })
72    } finally {
73      busy = false
74    }
75  })
76}
77
78// The origins of a prompt the user wrote themselves: Enter in the pane (gnat's
79// typing reaches it so) or a message through Remote Control. Every other
80// origin — nat's own sends through the inbox (a plugin's), a background
81// task's notification, a schedule, a peer — is not the user asking for more.
82const typed: ReadonlySet<string> = new Set(['composer', 'bridge'])
83
84// Puts a prompt the user typed at a slice's agent on the record as
85// `nat slice-resume`, the prompt its note, before the agent reads it — what
86// every nat send does itself before it sends. `slice-resume` writes nothing
87// where the slice is not handed back, so every typed prompt runs it; where it
88// is, the slice's board card reads as work in progress again. `NAT_SLICE` and
89// `NAT_PROJECT` are set by nat on a slice's launch alone. Nothing here throws:
90// a failure goes to the debug log, never the transcript, and never holds the
91// prompt.
92async function resume($: ResumeEngine, text: string): Promise<void> {
93  try {
94    const slice = await $.env.get('NAT_SLICE')
95    const project = await $.env.get('NAT_PROJECT')
96    if (!slice || !project) return
97    const { exitCode, stderr } = await $.process.run(
98      ['nat', 'slice-resume', slice, '--project', project, '--note', '-'],
99      { stdin: text, timeoutMs: 30_000 },
100    )
101    if (exitCode !== 0) $.ui.log(`nat slice-resume exited ${exitCode}: ${stderr.trim()}`, { to: 'debug' })
102  } catch (err) {
103    $.ui.log(`resume not recorded: ${String(err)}`, { to: 'debug' })
104  }
105}
106
107// nat's hooks into the Claude Code sessions it launches. Written against the
108// public mods reference and the declarations the installed build writes beside
109// a loaded mod; a hook that fails is skipped and a tree that does not validate
110// is replaced by Claude Code's own drawing, so a drift costs a feature, never
111// a session.
112//
113// An agent's pane in gnat is a viewport onto the agent, not a terminal the
114// user is learning key by key, so the chrome that teaches or decorates is
115// quieted. Each hook rewrites the engine's own props rather than drawing a
116// tree of its own, so Claude Code keeps drawing everything it knows; the
117// mode labels (`SessionMode`) are information and are left alone.
118export const register: Register = on => {
119  // Prompts nat sends this session arrive through its inbox; a session nat
120  // launched with none (an older tmux) is sent them by a paste instead.
121  on('session.start', async ($, e, next) => {
122    const dir = await $.env.get('NAT_INBOX')
123    if (dir) pollInbox($, dir)
124    return next(e)
125  })
126
127  // The session's brief, which nat leaves in a file (`NAT_BRIEF`) rather than
128  // in argv, rides the first user message as a context block beside
129  // CLAUDE.md's: the model reads it, the pane never draws it, and a
130  // compaction or `/clear` re-reads it here. The pane shows only nat's one
131  // opening line. A brief that cannot be read is logged and left out — the
132  // agent then has the opening line alone and asks, a visible failure.
133  on('prompt.context', async ($, e, next) => {
134    const path = await $.env.get('NAT_BRIEF')
135    if (!path) return next(e)
136    let text: string
137    try {
138      text = await $.fs.read(path)
139    } catch (err) {
140      $.ui.log(`agent brief not read: ${String(err)}`, { to: 'debug' })
141      return next(e)
142    }
143    return next({ ...e, blocks: [...e.blocks, { name: 'natBrief', text }] })
144  })
145
146  // The dim `? for shortcuts` / `esc to interrupt` line under the prompt
147  // draws empty. It is also the one visible mark that a session loaded this
148  // mod.
149  on('ui.render', { component: 'PromptHint' }, ($, e, next) =>
150    next({ ...e, props: { ...e.props, hint: '' } }),
151  )
152
153  // The `Baked for 3s` line closing each turn draws nothing.
154  on('ui.render', { component: 'TurnDuration' }, ($, e) => {
155    const { Box } = $.ui.resolve(e)
156    return h(Box, {})
157  })
158
159  // The dim notices under the logo (model source, experiment enrolment,
160  // settings hint) draw nothing.
161  on('ui.render', { component: 'InfoNotice' }, ($, e) => {
162    const { Box } = $.ui.resolve(e)
163    return h(Box, {})
164  })
165
166  // The `(ctrl+b to run in background)` pill under a tool call draws
167  // nothing: the user does not drive the agent's pane by key.
168  on('ui.render', { component: 'ToolProgress', props: { kind: 'background_hint' } }, ($, e, next) =>
169    next({ ...e, props: { ...e.props, hint: '' } }),
170  )
171
172  // The spinner says `Working` in place of the sampled flavour word; the
173  // message, suffix and mode stay the engine's, and so do the elapsed time
174  // and token count drawn after them.
175  on('ui.render', { component: 'Spinner' }, ($, e, next) =>
176    next({ ...e, props: { ...e.props, word: 'Working' } }),
177  )
178
179  // The waiting flag gnat's star, dock badge and Active rail read, set and
180  // cleared at the moments the engine itself knows are a wait on the user.
181  // The agent's own `nat agent-waiting` stays for what the engine cannot
182  // see — a question asked in prose at the end of a turn — so a plain
183  // finished turn (`answer`) and an `idle_prompt` notification mark nothing:
184  // that is a hand-back or a planning agent between prompts.
185  on('turn.start', async ($, e, next) => {
186    await mark($, null)
187    return next(e)
188  })
189
190  on('prompt.submit', async ($, e, next) => {
191    await mark($, null)
192    if (typed.has(e.origin.kind)) await resume($, e.text)
193    return next(e)
194  })
195
196  // An AskUserQuestion dialog waits from before it is drawn until it is
197  // answered, however it ends.
198  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
199    await mark($, 'ask')
200    try {
201      return await next(e)
202    } finally {
203      await mark($, null, ['ask'])
204    }
205  })
206
207  // A permission prompt is over once a tool call settles or the model is
208  // asked again.
209  on('tool.call', async ($, e, next) => {
210    try {
211      return await next(e)
212    } finally {
213      await mark($, null, ['permission'])
214    }
215  })
216
217  on('turn.step', async function* ($, e, next) {
218    await mark($, null, ['permission'])
219    return yield* next(e)
220  })
221
222  // A permission dialog is shown only where nothing beneath decided it.
223  on('classic.PermissionRequest', async ($, e, next) => {
224    const decided = await next(e)
225    if (!decided.decision) await mark($, 'permission')
226    return decided
227  })
228
229  on('classic.Notification', async ($, e, next) => {
230    if (e.notification_type === 'permission_prompt') await mark($, 'permission')
231    return next(e)
232  })
233
234  on('classic.Elicitation', async ($, e, next) => {
235    await mark($, 'elicitation')
236    return next(e)
237  })
238
239  on('classic.ElicitationResult', async ($, e, next) => {
240    await mark($, null, ['elicitation'])
241    return next(e)
242  })
243
244  // A main-loop turn that died on an API error or a refusal leaves the agent
245  // stuck until the user steps in; a subagent's is its spawner's to handle.
246  on('turn.complete', async ($, e, next) => {
247    if (!e.agentId && (e.reason === 'error' || e.reason === 'refusal')) await mark($, 'stuck')
248    return next(e)
249  })
250}
251
types/index.d.ts 12 lines
1// What the agent is waiting on the user for, as the engine itself knows it:
2// an AskUserQuestion dialog, a permission prompt, an MCP elicitation, or a
3// turn that ended stuck (an API error, a refusal).
4export type Wait = 'ask' | 'permission' | 'elicitation' | 'stuck'
5
6declare module 'claude-code' {
7  interface PluginState {
8    // `wait` is the flag the mod last wrote on the pane: null for working.
9    'nat-embedded': { wait: Wait | null }
10  }
11}
12