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

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.
hooks/register.ts 251 lines1import { 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}
251types/index.d.ts 12 lines1// 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