A side panel for Agentic-Z: DayZ status at a glance and one-click buttons for mount P:, build, the build and in game checks, deploy, launch, stop, Workbench…

A Claude Code panel for Agentic-Z. It shows the DayZ state beside the chat and has one-click buttons that run the Agentic-Z skill scripts directly, so a press costs no model tokens.
Windows only. The panel runs cmd /c, tasklist and %USERPROFILE%, and the scripts it calls need DayZ Tools and the P: drive.
/plugin install agentic-z-panel@dayz-n-chill
The panel opens by itself when a session starts. After closing it, click Agentic-Z panel just above the prompt, or run /dayz-panel.
Whether P: is mounted, the active mod (from /dayz-init), and whether a DayZ test session is running, each with a green or red dot. The buttons sit in framed cards (Build and check, Test session, Tools, Ask vanilla DayZ), and the last run shows in a card framed green or red by its outcome.
/dayz-panel also replies in the chat with the same state, where Agentic-Z was found, and the last run.
Three tabs sit at the top of the panel; click one or press 1, 2 or 3.
1): the status, the buttons, the ask field and the last run.2): the last vanilla answer, with Deep dive, Continue in chat and Dismiss. An ask opens this tab when it finishes, and a build or other run afterwards does not replace the answer.3): the API keys the Agentic-Z tools read, each with a field to paste a new key and Clear for one set here.| Key | Used by |
|---|---|
VOYAGE_API_KEY | vanilla source search: the ask field, /dayz-ask and the dayz-rag tools |
TYPESAFE_API_KEY | Jev, the routing model /dayz-route asks |
GITHUB_TOKEN | a higher GitHub rate limit for /dayz-search-download; git and gh read it too |
A key you save stays on your machine: in the panel's own store under your Claude Code folder, and in the session's environment, so the skill scripts, the agent's commands and MCP servers started afterwards read it. Every new session puts the saved keys back. The tab shows only where a key came from and its last four characters, never the key. An MCP server that was already running, such as dayz-rag, sees a new key after you restart Claude Code.
At session start the panel reads the marketplace list on GitHub. When it lists a newer panel, a toast, a line at the top of the panel and the /dayz-panel reply say so, with the command to run:
claude plugin update agentic-z-panel@dayz-n-chill
Then restart Claude Code. Offline, the check shows nothing.
| Button | Key | Runs |
|---|---|---|
| Check setup | e | dayz-preflight/preflight.py |
| Add test server | v | dayz-add-server/add_server.py chernarus |
| New mod (a text field) | Enter | dayz-new-mod/new_mod.py <name>; type MyVest by YourName for a first mod, to set the author |
| Import mod (a text field) | Enter | dayz-import-mod/import_mod.py --source <path or GitHub link> |
| Mount P: | p | dayz-workdrive/mount.py |
| Build PBO | b | dayz-build-pbo/build.py <active mod> |
| Check build | c | dayz-gate/gate.py build <active mod> |
| Check in game | g | dayz-gate/gate.py ingame <active mod> |
| Deploy prebuilt | d | dayz-deploy-pbo/deploy.py <active mod> |
| Launch | l | dayz-launch-test/launch.py <active mod> |
| Stop | s | dayz-stop-test/stop_test.bat |
| Workbench | w | dayz-launch-workbench/launch.py --mod <active mod> |
| Object Builder | o | dayz-launch-objectbuilder/launch.py |
| Logs | t | dayz-logwatch/logwatch.py --since 30 --duration 3 --mod <active mod> |
| Health page | h | dayz-health/health.py <active mod> |
| Release pack | k | dayz-release-pack/release_pack.py <active mod> |
| Ask vanilla (a text field) | Enter | dayz-ask/ask.py --top-k <Sources> --excerpt-lines <Excerpt> "<question>" |
| Deep dive (under an answer) | v | dayz-ask/ask.py --top-k 25 --excerpt-lines 200 "<question>", then a longer answer |
| Continue in chat (under an answer) | m | puts the question and answer in the prompt box for the main conversation |
| Refresh | r | re-checks the state |
Three pickers under the ask field set the next ask: Sources (5 to 25 citations, default 10), Excerpt (60 to 200 lines per citation, default 60) and Answer (Short, Normal or Long, default Normal). The lists are the limits, since more of each is slower and costs more tokens. The picks are kept for the next session. Deep dive always uses the top of each list.
The Python scripts run with python when it works, otherwise the py launcher, the same as the scripts\dayz wrappers. With neither, the button runs nothing and shows a Python 3 not found message. Scripts run with UTF-8 output, so an answer that quotes a non-ASCII character cannot crash them.
One action runs at a time; a second press while one runs only shows a toast. Every press reads the state again first, so a mod picked with /dayz-init after the panel opened is used without pressing Refresh. Each run shows a toast when it ends and keeps its result line in the panel. An ask keeps its verdict with the reason or notes under it, and one that finds nothing (NONE) shows "nothing found", not "failed".
The clone that holds the session folder (that folder or the nearest parent with .claude\skills\dayz-preflight\preflight.py), otherwise the newest version in the plugin cache under %USERPROFILE%\.claude\plugins\cache\dayz-n-chill\agentic-z.
The active mod comes from .claude\local-memory\dayz-init-state.json in that clone. With no clone (a plugin install), it comes from the nearest folder at or above the session folder that holds that file, stopping before the home folder (%USERPROFILE%\.claude is Claude Code's user config, and the skill scripts ignore a state file there too).
claude plugin test mods/agentic-z-panel
scripts/dev/test_repo_integrity.py also checks that every script the buttons name exists, so a renamed skill script fails the pytest run.
More detail: docs/token-saving.md, section 8.
hooks/register.tsx 787 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { AskSettings, KeyStatus, PanelRun, PanelStatus, PanelTab } from '../types'
5
6// One pane with the DayZ state and buttons that run Agentic-Z's skill scripts
7// directly. The buttons cost no tokens; only the ask field calls the model, to
8// turn the cited vanilla code into a plain-English answer.
9const PANE = 'agentic-z'
10const status = atom({ plugin: 'agentic-z-panel', key: 'status' } as const, null)
11const lastRun = atom({ plugin: 'agentic-z-panel', key: 'lastRun' } as const, null)
12const busy = atom({ plugin: 'agentic-z-panel', key: 'busy' } as const, null)
13const newVersion = atom({ plugin: 'agentic-z-panel', key: 'newVersion' } as const, null)
14const askSettings = atom({ plugin: 'agentic-z-panel', key: 'askSettings' } as const, {
15 topK: '10',
16 excerptLines: '60',
17 words: '400',
18})
19// An ask's answer has its own atom, so a build after it does not wipe it.
20const lastAnswer = atom({ plugin: 'agentic-z-panel', key: 'lastAnswer' } as const, null)
21const tab = atom({ plugin: 'agentic-z-panel', key: 'tab' } as const, 'panel' as PanelTab)
22const keys = atom({ plugin: 'agentic-z-panel', key: 'keys' } as const, {} as KeyStatus)
23
24// The keys the Keys tab sets. They stay on this machine: in the panel's own
25// store under the Claude Code config folder, and in this process's environment,
26// so the skill scripts, the agent's commands and MCP servers started after read them.
27const KEY_LIST = [
28 { name: 'VOYAGE_API_KEY', label: 'Voyage AI', what: 'vanilla source search: the Ask tab, /dayz-ask and the dayz-rag tools' },
29 { name: 'TYPESAFE_API_KEY', label: 'TypeSafe', what: 'Jev, the routing model /dayz-route asks' },
30 { name: 'GITHUB_TOKEN', label: 'GitHub token', what: 'a higher GitHub rate limit for /dayz-search-download; git and gh use it too' },
31] as const
32type KeyName = (typeof KEY_LIST)[number]['name']
33const KEYS_STORE = 'apiKeys'
34
35// $.env takes a variable name only as a string literal, so each name is spelled out.
36const envGet = ($: any, name: KeyName): Promise<string | undefined> => {
37 if (name === 'VOYAGE_API_KEY') return $.env.get('VOYAGE_API_KEY')
38 if (name === 'TYPESAFE_API_KEY') return $.env.get('TYPESAFE_API_KEY')
39 return $.env.get('GITHUB_TOKEN')
40}
41const envSet = ($: any, name: KeyName, value: string | undefined): Promise<{ deny?: string }> => {
42 if (name === 'VOYAGE_API_KEY') return $.env.set('VOYAGE_API_KEY', value)
43 if (name === 'TYPESAFE_API_KEY') return $.env.set('TYPESAFE_API_KEY', value)
44 return $.env.set('GITHUB_TOKEN', value)
45}
46
47const storedKeys = async ($: any): Promise<Partial<Record<KeyName, string>>> => {
48 const saved = await $.store.get(KEYS_STORE).catch(() => null)
49 return saved && typeof saved === 'object' ? saved : {}
50}
51
52// The tab shows where each key comes from and its last four characters, never the key.
53async function readKeys($: any) {
54 const saved = await storedKeys($)
55 const next: KeyStatus = {}
56 for (const { name } of KEY_LIST) {
57 const value = saved[name] ?? (await envGet($, name).catch(() => undefined))
58 next[name] = value ? { source: saved[name] ? 'panel' : 'environment', tail: value.slice(-4) } : null
59 }
60 await update($, keys, () => next)
61}
62
63// value undefined clears the key: from the store and from the environment.
64async function saveKey($: any, name: KeyName, value: string | undefined) {
65 const set = await envSet($, name, value)
66 if (set?.deny) {
67 $.ui.toast(`${name}: not set (${set.deny})`)
68 return
69 }
70 const saved = await storedKeys($)
71 if (value) saved[name] = value
72 else delete saved[name]
73 await $.store.set(KEYS_STORE, saved)
74 await readKeys($)
75 $.ui.toast(value ? `${name} saved on this machine.` : `${name} cleared.`)
76}
77
78const SKILL = {
79 preflight: ['dayz-preflight', 'preflight.py'],
80 newmod: ['dayz-new-mod', 'new_mod.py'],
81 importmod: ['dayz-import-mod', 'import_mod.py'],
82 addserver: ['dayz-add-server', 'add_server.py'],
83 mount: ['dayz-workdrive', 'mount.py'],
84 build: ['dayz-build-pbo', 'build.py'],
85 deploy: ['dayz-deploy-pbo', 'deploy.py'],
86 gatebuild: ['dayz-gate', 'gate.py'],
87 gateingame: ['dayz-gate', 'gate.py'],
88 launch: ['dayz-launch-test', 'launch.py'],
89 workbench: ['dayz-launch-workbench', 'launch.py'],
90 objectbuilder: ['dayz-launch-objectbuilder', 'launch.py'],
91 logwatch: ['dayz-logwatch', 'logwatch.py'],
92 releasepack: ['dayz-release-pack', 'release_pack.py'],
93 health: ['dayz-health', 'health.py'],
94 ask: ['dayz-ask', 'ask.py'],
95 deepdive: ['dayz-ask', 'ask.py'],
96} as const
97
98// What a person may pick before an ask. The lists are the limits: more
99// sources, longer excerpts and longer answers are slower and cost more tokens,
100// so the top of each list is as far as an ask goes. Deep dive is that top.
101const ASK_CHOICES = {
102 topK: [
103 { value: '5', label: '5' },
104 { value: '10', label: '10' },
105 { value: '15', label: '15' },
106 { value: '20', label: '20' },
107 { value: '25', label: '25 (most)' },
108 ],
109 excerptLines: [
110 { value: '60', label: '60 lines' },
111 { value: '100', label: '100 lines' },
112 { value: '150', label: '150 lines' },
113 { value: '200', label: '200 lines (most)' },
114 ],
115 words: [
116 { value: '300', label: 'Short' },
117 { value: '400', label: 'Normal' },
118 { value: '900', label: 'Long' },
119 ],
120} as const
121const ASK_DEFAULTS: AskSettings = { topK: '10', excerptLines: '60', words: '400' }
122const DEEP_DIVE: AskSettings = { topK: '25', excerptLines: '200', words: '900' }
123const STORE_KEY = 'askSettings'
124const isAsk = (action: string) => action === 'ask' || action === 'deepdive'
125
126// A stored pick from an older version may no longer be on the list: each field
127// off the list goes back to its default.
128const allowed = (saved: any): AskSettings => {
129 const pick = (field: keyof AskSettings) =>
130 ASK_CHOICES[field].some(o => o.value === saved?.[field]) ? saved[field] : ASK_DEFAULTS[field]
131 return { topK: pick('topK'), excerptLines: pick('excerptLines'), words: pick('words') }
132}
133
134// The map the "Add test server" button sets up; /dayz-add-server takes others.
135const DEFAULT_SERVER = 'chernarus'
136
137// "MyVest" or, for a first mod with no cached author, "MyVest by YourName".
138const NEW_MOD_INPUT = /^(\S+)(?:\s+by\s+(.+))?$/i
139
140// How far back the logs button reads, and how long it then watches. A button
141// must return, so it reads the recent session lines once instead of following.
142const LOGWATCH_SINCE_MINUTES = '30'
143const LOGWATCH_SECONDS = '3'
144
145const join = (...parts: string[]) => parts.join('\\')
146
147const trimPath = (p: string) => p.replace(/\//g, '\\').replace(/\\+$/, '')
148
149// cwd itself or the nearest parent that holds the given path, so a session
150// started in a subfolder (workspace\MyVest) still finds the project. The walk
151// stops before `stop` (and returns null) when one is given.
152async function findUp($: any, cwd: string, rel: string, stop?: string): Promise<string | null> {
153 let dir = trimPath(cwd)
154 const stopAt = stop ? trimPath(stop).toLowerCase() : null
155 while (dir) {
156 if (stopAt && dir.toLowerCase() === stopAt) return null
157 if (await $.fs.exists(join(dir, rel))) return dir
158 const cut = dir.lastIndexOf('\\')
159 if (cut < 0) return null
160 dir = dir.slice(0, cut)
161 }
162 return null
163}
164
165// The clone that holds cwd: the nearest folder with Agentic-Z's skills.
166const findClone = ($: any, cwd: string) => findUp($, cwd, join('.claude', 'skills', 'dayz-preflight', 'preflight.py'))
167
168const homeDir = async ($: any): Promise<string> =>
169 (await $.process.run(['cmd', '/c', 'echo %USERPROFILE%'])).stdout.trim()
170
171// Agentic-Z lives in the project itself (a clone) or in the plugin cache.
172async function findRoot($: any, cwd: string): Promise<string | null> {
173 const clone = await findClone($, cwd)
174 if (clone) return clone
175 const home = await homeDir($)
176 const cache = join(home, '.claude', 'plugins', 'cache', 'dayz-n-chill', 'agentic-z')
177 if (!(await $.fs.exists(cache))) return null
178 // Numeric order, so 1.10.0 sorts after 1.9.0.
179 const versions = (await $.fs.list(cache))
180 .filter((v: { kind: string }) => v.kind === 'dir')
181 .map((v: { name: string }) => v.name)
182 .sort((a: string, b: string) => a.localeCompare(b, undefined, { numeric: true }))
183 return versions.length ? join(cache, versions[versions.length - 1]) : null
184}
185
186// The state file of the clone, or with no clone (a plugin install) of the
187// nearest folder at or above cwd that has one. Like dayz-init's state.py, the
188// walk stops before the home folder: ~\.claude is Claude Code's user config,
189// not a project, and the scripts ignore a state file there.
190async function activeMod($: any, cwd: string): Promise<string | null> {
191 const state = join('.claude', 'local-memory', 'dayz-init-state.json')
192 const project = (await findClone($, cwd)) ?? (await findUp($, cwd, state, await homeDir($)))
193 if (!project) return null
194 const file = join(project, state)
195 if (!(await $.fs.exists(file))) return null
196 try {
197 return JSON.parse(await $.fs.read(file))?.last_intent?.mod_name ?? null
198 } catch {
199 return null
200 }
201}
202
203async function refresh($: any, cwd: string): Promise<PanelStatus> {
204 const [root, mod, isPMounted, tasks] = await Promise.all([
205 findRoot($, cwd),
206 activeMod($, cwd),
207 $.fs.exists('P:\\'),
208 // A failed tasklist call means the session state is unknown, not an error.
209 $.process.run(['tasklist', '/FI', 'IMAGENAME eq DayZDiag_x64.exe', '/NH']).catch(() => ({ stdout: '' })),
210 ])
211 const next: PanelStatus = {
212 root,
213 mod,
214 isPMounted,
215 isDiagRunning: tasks.stdout.includes('DayZDiag_x64.exe'),
216 checkedAt: await $.clock.now(),
217 }
218 await update($, status, () => next)
219 return next
220}
221
222// One action at a time: a second press would start a second AddonBuilder run
223// or a second DayZ session beside the first. The lock is checked and taken
224// before any await, so two quick presses cannot both get past it; the busy
225// atom only drives the "Running..." line in the pane.
226let running: string | null = null
227
228async function runAction($: any, cwd: string, action: string, question?: string) {
229 if (running) {
230 $.ui.toast(`${running} is still running`)
231 return
232 }
233 running = action
234 try {
235 await update($, busy, () => action)
236 await runNow($, cwd, action, question)
237 } finally {
238 running = null
239 await update($, busy, () => null)
240 }
241}
242
243const explainSystem = (words: number) => `You explain how vanilla DayZ works to a mod author, using only the cited Enforce Script and config excerpts you are given.
244Answer in plain English first: what happens, when, and why, as a short overview and then the key steps or numbers.
245Name the classes, methods and constants that matter in backticks, and cite where each fact comes from as [n] (path:line) using the citation numbers given.
246If the excerpts do not cover part of the question, say so plainly instead of guessing.
247Use short markdown sections or bullets. Keep it under ${words} words.`
248
249// Turns ask.py's citations into an answer, or null when the model gave none:
250// the citations still show, so a failed call loses nothing. The excerpts can be
251// long; the model needs the evidence, not all of it.
252async function explain($: any, settings: AskSettings, question: string, evidence: string): Promise<{ answer?: string; why?: string }> {
253 const words = Number(settings.words)
254 const evidenceChars = Math.min(150_000, Number(settings.topK) * Number(settings.excerptLines) * 100)
255 try {
256 const result = await $.model.complete({
257 model: 'sonnet',
258 system: explainSystem(words),
259 prompt: `Question: ${question}\n\nEvidence from /dayz-ask:\n${evidence.slice(0, evidenceChars)}`,
260 maxTokens: words * 5,
261 })
262 if (result.isAnswered) return { answer: result.text.trim() }
263 return { why: [result.reason, result.status, result.error].filter(Boolean).join(' ') }
264 } catch (err) {
265 return { why: String(err) }
266 }
267}
268
269const PYTHON_MISSING ='Python 3 not found: install it from python.org or add python/py to PATH'
270
271// The interpreter the scripts\dayz wrappers use: python when it runs, else the py
272// launcher. Running it, not just finding it, skips the Microsoft Store alias,
273// which is on PATH but fails.
274async function findPython($: any): Promise<string | null> {
275 for (const exe of ['python', 'py']) {
276 try {
277 if ((await $.process.run([exe, '-c', ''], { timeoutMs: 15_000 })).exitCode === 0) return exe
278 } catch {
279 // not on PATH: try the next one
280 }
281 }
282 return null
283}
284
285// An ask's result goes to the Answer tab and shows it; every other run stays on the Panel tab.
286async function record($: any, run: NonNullable<PanelRun>) {
287 if (isAsk(run.action)) {
288 await update($, lastAnswer, () => run)
289 await update($, tab, () => 'answer')
290 } else {
291 await update($, lastRun, () => run)
292 }
293}
294
295async function runNow($: any, cwd: string, action: string, question?: string) {
296 // Read the state afresh: /dayz-init may have run since the pane last refreshed.
297 let current: PanelStatus
298 try {
299 current = await refresh($, cwd)
300 } catch (err) {
301 await record($, { action, ok: false, outcome: 'failed', detail: `status check failed: ${err}` })
302 $.ui.toast(`${action}: failed (status check: ${err})`)
303 return
304 }
305 if (!current.root) {
306 $.ui.toast('Agentic-Z not found in this project or the plugin cache.')
307 return
308 }
309 let argv: string[]
310 let settings: AskSettings | undefined
311 if (action === 'stop') {
312 argv = ['cmd', '/c', join(current.root, '.claude', 'skills', 'dayz-stop-test', 'stop_test.bat')]
313 } else {
314 const [skill, script] = SKILL[action as keyof typeof SKILL]
315 const python = await findPython($)
316 if (!python) {
317 await record($, { action, ok: false, outcome: 'failed', detail: PYTHON_MISSING })
318 $.ui.toast(`${action}: failed (${PYTHON_MISSING})`)
319 return
320 }
321 argv = [python, join(current.root, '.claude', 'skills', skill, script)]
322 if (action === 'gatebuild') argv.push('build')
323 if (action === 'gateingame') argv.push('ingame')
324 const needsMod = ['build', 'deploy', 'gatebuild', 'gateingame', 'launch', 'releasepack', 'health'].includes(action)
325 if (isAsk(action)) {
326 if (!question?.trim()) {
327 $.ui.toast('Type a question about vanilla DayZ first.')
328 return
329 }
330 settings = action === 'deepdive' ? DEEP_DIVE : await read($, askSettings)
331 argv.push('--top-k', settings.topK, '--excerpt-lines', settings.excerptLines, question.trim())
332 } else if (action === 'newmod') {
333 const match = question?.trim().match(NEW_MOD_INPUT)
334 if (!match) {
335 $.ui.toast('Type a mod name, such as MyVest (first mod: MyVest by YourName).')
336 return
337 }
338 argv.push(match[1])
339 if (match[2]) argv.push('--author', match[2].trim())
340 } else if (action === 'importmod') {
341 if (!question?.trim()) {
342 $.ui.toast('Type the mod folder path or a GitHub link first.')
343 return
344 }
345 argv.push('--source', question.trim())
346 } else if (action === 'addserver') {
347 argv.push(DEFAULT_SERVER)
348 } else if (action === 'logwatch') {
349 argv.push('--since', LOGWATCH_SINCE_MINUTES, '--duration', LOGWATCH_SECONDS)
350 if (current.mod) argv.push('--mod', current.mod)
351 } else if (needsMod) {
352 if (!current.mod) {
353 $.ui.toast('No active mod yet. Run /dayz-init first.')
354 return
355 }
356 argv.push(current.mod)
357 } else if (action === 'workbench' && current.mod) {
358 argv.push('--mod', current.mod)
359 }
360 }
361 let run: PanelRun
362 try {
363 // Captured output is a pipe, where Python on Windows writes cp1252: a vanilla
364 // line with a character such as U+2192 would crash the script mid-answer.
365 // A working folder that no longer exists (a removed worktree, say) makes the
366 // spawn fail with ENOENT naming the program, so fall back to the Agentic-Z root.
367 const runCwd = (await $.fs.exists(cwd)) ? cwd : current.root
368 const result = await $.process.run(argv, { cwd: runCwd, timeoutMs: 600_000, env: { PYTHONIOENCODING: 'utf-8' } })
369 const lines = (result.stdout + '\n' + result.stderr).trim().split(/\r?\n/)
370 const failLine = lines.find((l: string) => l.startsWith('[FAIL]'))
371 // An answer is the verdict, its reason or notes (the lines indented two
372 // spaces under it) and the citation headers; everything else is one line.
373 const detail = isAsk(action)
374 ? lines.filter((l: string) => /^(EVIDENCE|NONE|ERROR|\[\d+\]| {2}\S)/.test(l)).slice(0, 8).join('\n')
375 : (failLine ?? lines[lines.length - 1] ?? '')
376 // ask.py exits 1 for NONE: the search worked and found nothing.
377 const nothingFound = isAsk(action) && result.exitCode === 1
378 const ok = result.exitCode === 0 || nothingFound
379 run = { action, ok, outcome: nothingFound ? 'nothing found' : ok ? 'done' : 'failed', detail }
380 if (isAsk(action)) run.question = question!.trim()
381 if (isAsk(action) && result.exitCode === 0) {
382 await update($, busy, () => `${action} (writing the answer)`)
383 const { answer, why } = await explain($, settings!, question!.trim(), result.stdout)
384 run = answer ? { ...run, answer } : { ...run, detail: `(no explanation: ${why})\n${detail}` }
385 }
386 } catch (err) {
387 run = { action, ok: false, outcome: 'failed', detail: String(err) }
388 }
389 await record($, run)
390 $.ui.toast(`${action}: ${run.outcome}`)
391 await refreshQuietly($, cwd)
392}
393
394// Hands the answer to the main conversation, which can read whole files and
395// take follow-up questions. It fills the prompt box rather than sending, so the
396// person adds their own follow-up first.
397async function continueInChat($: any, run: NonNullable<PanelRun>) {
398 const text =
399 `Explain in more depth how vanilla DayZ handles this: ${run.question}\n\n` +
400 `The Agentic-Z panel's short answer, from /dayz-ask citations:\n\n${run.answer}\n\n` +
401 'Read the cited vanilla files in full, with /dayz-ask or the dayz-rag tools for anything more, ' +
402 'and fill in what the short answer could not cover.'
403 const filled = await $.prompt.fill({ text })
404 $.ui.toast(filled.isFilled ? 'Added to the prompt box: add your follow-up and press Enter.' : 'Could not fill the prompt box.')
405}
406
407// The marketplace's own list, so the panel can tell its users a newer version
408// is out; a plugin install does not update itself unless auto-update is on.
409const MARKETPLACE_URL = 'https://raw.githubusercontent.com/DayZ-n-Chill/Agentic-Z/main/.claude-plugin/marketplace.json'
410const UPDATE_COMMAND = 'claude plugin update agentic-z-panel@dayz-n-chill'
411
412// The newer version the marketplace lists, or null. Offline, a bad reply or a
413// local copy newer than main all mean no notice, never an error.
414async function checkForUpdate($: any) {
415 try {
416 const mine = JSON.parse(await $.fs.read(join(trimPath($.plugin.root), '.claude-plugin', 'plugin.json'))).version
417 const res = await $.http.fetch(MARKETPLACE_URL)
418 if (!res.ok) return
419 const latest = JSON.parse(res.text).plugins?.find((p: { name: string }) => p.name === 'agentic-z-panel')?.version
420 if (!latest || latest.localeCompare(mine, undefined, { numeric: true }) <= 0) return
421 await update($, newVersion, () => latest)
422 $.ui.toast(`Agentic-Z panel ${latest} is out: run ${UPDATE_COMMAND}`)
423 } catch {
424 // no notice
425 }
426}
427
428// A status refresh nobody waits on must not throw: a failed process call
429// would otherwise be an unhandled rejection.
430const refreshQuietly = ($: any, cwd: string) => refresh($, cwd).catch(() => null)
431
432export const register: Register = on => {
433 let cwd = '.'
434
435 on('session.start', async ($, e, next) => {
436 cwd = e.cwd
437 await $.command.register({ name: 'dayz-panel', description: 'Open the Agentic-Z panel' })
438 void refreshQuietly($, cwd)
439 void checkForUpdate($)
440 // The last picks carry over to the next session.
441 void $.store
442 .get(STORE_KEY)
443 .then((saved: unknown) => update($, askSettings, () => allowed(saved)))
444 .catch(() => null)
445 // Keys saved on the Keys tab go back into the environment before anything runs.
446 const saved = await storedKeys($)
447 for (const { name } of KEY_LIST) {
448 if (saved[name]) await envSet($, name, saved[name]).catch(() => null)
449 }
450 void readKeys($).catch(() => null)
451 await update($, tab, () => 'panel')
452 // Every session opens the panel itself: nobody should have to type
453 // /dayz-panel to get it. A narrow terminal holds it until there is room.
454 void $.ui.open({ id: PANE, title: 'Agentic-Z' }).catch(() => null)
455 return next(e)
456 })
457
458 // One click above the prompt opens the pane, so nobody has to type /dayz-panel.
459 // It steps aside while a survey uses the band.
460 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
461 if (e.props.hasSurvey) return next(e)
462 const { Box, Button } = $.ui.resolve(e)
463 // Other mods' bands (the status strip) draw beneath this button, so they must still run.
464 const below = await next(e)
465 return (
466 <Box key="open-panel-band" flexDirection="column">
467 <Button
468 key="open-panel"
469 label="Agentic-Z panel"
470 plain
471 dimColor
472 hover={{ backgroundColor: 'subtle' }}
473 onPress={async () => {
474 await refreshQuietly($, cwd)
475 await $.ui.open({ id: PANE, title: 'Agentic-Z' })
476 }}
477 />
478 {below}
479 </Box>
480 )
481 })
482
483 on('command.run', { command: 'dayz-panel' }, async $ => {
484 const s = await refresh($, cwd)
485 const run = await read($, lastRun)
486 await $.ui.open({ id: PANE, title: 'Agentic-Z' })
487 const lines = [
488 'Agentic-Z panel opened.',
489 ` P: drive ${s.isPMounted ? 'mounted' : 'not mounted (press p in the panel)'}`,
490 ` Active mod ${s.mod ?? 'none (run /dayz-init)'}`,
491 ` Test session ${s.isDiagRunning ? 'running' : 'stopped'}`,
492 ` Agentic-Z ${s.root ?? 'not found in this project or the plugin cache'}`,
493 ]
494 if (run) lines.push(` Last run ${run.action} ${run.outcome}`)
495 const latest = await read($, newVersion)
496 if (latest) lines.push(` Update ${latest} is out: run ${UPDATE_COMMAND}, then restart`)
497 return { text: lines.join('\n') }
498 })
499
500 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
501 const { Box, Button, Input, Markdown, Select, Text } = $.ui.resolve(e)
502 const s = await read($, status)
503 const run = await read($, lastRun)
504 const picks = await read($, askSettings)
505 const choose = (field: keyof AskSettings) => async (value: string) => {
506 const next = allowed({ ...picks, [field]: value })
507 await update($, askSettings, () => next)
508 await $.store.set(STORE_KEY, next)
509 }
510 const working = await read($, busy)
511 const latest = await read($, newVersion)
512 const press = (action: string) => () => runAction($, cwd, action)
513
514 // A titled card; the terminal draws it as a rounded frame, the app as a panel.
515 // A plain card's frame is dimmed so it groups without competing with the
516 // buttons; the last run's green or red frame stays at full strength.
517 const card = (title: string, children: any, borderColor = 'subtle') => (
518 <Box
519 flexDirection="column"
520 borderStyle="round"
521 borderColor={borderColor}
522 borderDimColor={borderColor === 'subtle'}
523 paddingX={1}
524 >
525 <Text weight="bold" size="heading" color="claude">
526 {title}
527 </Text>
528 {children}
529 </Box>
530 )
531 // One status line with a colored dot: green when good, red when not.
532 const light = (isGood: boolean, text: string) => (
533 <Box flexDirection="row" gap={1}>
534 <Text color={isGood ? 'success' : 'error'}>●</Text>
535 <Text>{text}</Text>
536 </Box>
537 )
538 // Plain buttons have no filled chrome, so a row of them reads as separate
539 // controls instead of one gray block; each lights gray under the pointer
540 // (its own hover scope). Plain draws the hotkey itself: "b: Build PBO".
541 const btn = (action: string, label: string, hotkey: string | undefined, onPress = press(action)) => (
542 <Button
543 key={action}
544 label={label}
545 hotkey={hotkey}
546 plain
547 hover={{ scope: `btn-${action}`, backgroundColor: 'subtle' }}
548 onPress={onPress}
549 />
550 )
551 // A labelled row: the fixed-width label lines the rows of a card up, and
552 // the buttons wrap under each other on a narrow pane.
553 const row = (label: string, children: any) => (
554 <Box flexDirection="row" columnGap={1}>
555 <Box width={7} flexShrink={0}>
556 <Text dimColor>{label}</Text>
557 </Box>
558 <Box flexDirection="row" flexWrap="wrap" flexGrow={1} columnGap={2} rowGap={1}>
559 {children}
560 </Box>
561 </Box>
562 )
563 const rows = (children: any) => (
564 <Box flexDirection="column" rowGap={1} paddingTop={1}>
565 {children}
566 </Box>
567 )
568 // The last run's card: green or red frame, the answer as markdown when an ask
569 // gave one, else the run's detail line.
570 const runCard = (r: NonNullable<PanelRun>, clear: () => unknown) =>
571 card(
572 r.ok ? `✓ ${r.action}: ${r.outcome}` : `✗ ${r.action}: ${r.outcome}`,
573 <Box flexDirection="column" rowGap={1}>
574 {r.answer ? (
575 <Markdown key="answer" text={r.answer} />
576 ) : (
577 <Text dimColor>
578 Last: {r.action} {r.outcome} {r.detail}
579 </Text>
580 )}
581 <Box flexDirection="row" flexWrap="wrap" columnGap={2} rowGap={1}>
582 {r.answer && r.action === 'ask' &&
583 btn('deepdive', 'Deep dive', 'v', () => runAction($, cwd, 'deepdive', r.question))}
584 {r.answer && btn('continue', 'Continue in chat', 'm', () => continueInChat($, r))}
585 {btn('dismiss-run', 'Dismiss', 'x', clear)}
586 </Box>
587 </Box>,
588 r.ok ? 'success' : 'error',
589 )
590
591 const current = await read($, tab)
592 const answer = await read($, lastAnswer)
593 const keyStatus = await read($, keys)
594 const TABS: { id: PanelTab; label: string; hotkey: string }[] = [
595 { id: 'panel', label: 'Panel', hotkey: '1' },
596 { id: 'answer', label: 'Answer', hotkey: '2' },
597 { id: 'keys', label: 'Keys', hotkey: '3' },
598 ]
599 const tabBar = (
600 <Box key="tabs" flexDirection="row" columnGap={2}>
601 {TABS.map(t => (
602 <Button
603 key={`tab-${t.id}`}
604 label={current === t.id ? `▸ ${t.label}` : t.label}
605 hotkey={t.hotkey}
606 plain
607 dimColor={current !== t.id}
608 hover={{ scope: `tab-${t.id}`, backgroundColor: 'subtle' }}
609 onPress={() => update($, tab, () => t.id)}
610 />
611 ))}
612 </Box>
613 )
614
615 const panelTab = [
616 card(
617 'Status',
618 <Box flexDirection="row" columnGap={2} alignItems="flex-start">
619 <Box flexDirection="column" flexGrow={1}>
620 {light(!!s?.isPMounted, `P: drive: ${s?.isPMounted ? 'mounted' : 'not mounted'}`)}
621 {light(!!s?.mod, `Active mod: ${s?.mod ?? 'none (run /dayz-init)'}`)}
622 {light(!!s?.isDiagRunning, `Test session: ${s?.isDiagRunning ? 'running' : 'stopped'}`)}
623 {!s?.root && <Text color="warning">Agentic-Z not found here.</Text>}
624 </Box>
625 {btn('refresh', 'Refresh', 'r', () => refreshQuietly($, cwd))}
626 </Box>,
627 ),
628 card(
629 'Start a project',
630 rows([
631 row('Setup', [
632 btn('preflight', 'Check setup', 'e'),
633 btn('addserver', `Add test server (${DEFAULT_SERVER})`, 'v'),
634 ]),
635 Input && (
636 <Input
637 key="newmod"
638 label="New mod:"
639 placeholder="MyVest (first mod: MyVest by YourName)"
640 submitLabel="create"
641 onSubmit={(text: string) => runAction($, cwd, 'newmod', text)}
642 />
643 ),
644 Input && (
645 <Input
646 key="importmod"
647 label="Import mod:"
648 placeholder="folder path or GitHub link"
649 submitLabel="import"
650 onSubmit={(text: string) => runAction($, cwd, 'importmod', text)}
651 />
652 ),
653 ]),
654 ),
655 card(
656 'Build and test',
657 rows([
658 row('Build', [
659 btn('build', 'Build PBO', 'b'),
660 btn('gatebuild', 'Check build', 'c'),
661 btn('deploy', 'Deploy prebuilt', 'd'),
662 ]),
663 row('Play', [
664 btn('launch', 'Launch', 'l'),
665 btn('gateingame', 'Check in game', 'g'),
666 btn('stop', 'Stop', 's'),
667 btn('logwatch', 'Logs', 't'),
668 ]),
669 ]),
670 ),
671 card(
672 'Tools',
673 rows([
674 row('Open', [
675 btn('mount', 'Mount P:', 'p'),
676 btn('workbench', 'Workbench', 'w'),
677 btn('objectbuilder', 'Object Builder', 'o'),
678 ]),
679 row('Ship', [btn('health', 'Health page', 'h'), btn('releasepack', 'Release pack', 'k')]),
680 ]),
681 ),
682 Input &&
683 card(
684 'Ask vanilla DayZ',
685 <Box flexDirection="column" rowGap={1}>
686 <Input
687 key="ask"
688 label="Question:"
689 placeholder="how does food spoil"
690 submitLabel="ask"
691 onSubmit={(question: string) => runAction($, cwd, 'ask', question)}
692 />
693 {Select && (
694 <Box flexDirection="row" flexWrap="wrap" columnGap={2} rowGap={1}>
695 <Select key="ask-topk" label="Sources:" options={ASK_CHOICES.topK} value={picks.topK} onSelect={choose('topK')} />
696 <Select
697 key="ask-lines"
698 label="Excerpt:"
699 options={ASK_CHOICES.excerptLines}
700 value={picks.excerptLines}
701 onSelect={choose('excerptLines')}
702 />
703 <Select key="ask-words" label="Answer:" options={ASK_CHOICES.words} value={picks.words} onSelect={choose('words')} />
704 </Box>
705 )}
706 <Text dimColor>The answer opens on the Answer tab.</Text>
707 </Box>,
708 ),
709 run && runCard(run, () => update($, lastRun, () => null)),
710 ]
711
712 const answerTab = answer ? (
713 <Box flexDirection="column" gap={1}>
714 {answer.question && <Text dimColor>Asked: {answer.question}</Text>}
715 {runCard(answer, () => update($, lastAnswer, () => null))}
716 </Box>
717 ) : (
718 <Text dimColor>No answer yet. Ask a question on the Panel tab.</Text>
719 )
720
721 // Each key: where it comes from and its last four characters, a field to
722 // set a new one (it is never drawn back), and Clear for one the panel set.
723 const keysTab = card(
724 'API keys',
725 <Box flexDirection="column" rowGap={1} paddingTop={1}>
726 <Text dimColor>
727 Your keys stay on this machine, in the panel's store in your Claude Code folder and in this session's
728 environment. Each is sent only to the service it is for.
729 </Text>
730 {KEY_LIST.map(k => {
731 const at = keyStatus[k.name]
732 return (
733 <Box key={`key-${k.name}-box`} flexDirection="column">
734 <Box flexDirection="row" columnGap={1}>
735 <Text color={at ? 'success' : 'error'}>●</Text>
736 <Text weight="bold">{k.label}</Text>
737 <Text dimColor>
738 {k.name}: {!at ? 'not set' : at.source === 'panel' ? `set here (…${at.tail})` : `set in your environment (…${at.tail})`}
739 </Text>
740 </Box>
741 <Text dimColor>For {k.what}.</Text>
742 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
743 {Input && (
744 <Input
745 key={`key-${k.name}`}
746 label="New key:"
747 placeholder="paste it and press Enter"
748 submitLabel="save"
749 onSubmit={(text: string) => {
750 const value = text.trim()
751 if (!value) {
752 $.ui.toast(`Paste a ${k.label} key first.`)
753 return
754 }
755 return saveKey($, k.name, value)
756 }}
757 />
758 )}
759 {at?.source === 'panel' && btn(`clear-${k.name}`, 'Clear', undefined, () => saveKey($, k.name, undefined))}
760 </Box>
761 </Box>
762 )
763 })}
764 <Text dimColor>An MCP server that was already running, such as dayz-rag, picks up a new key after you restart Claude Code.</Text>
765 </Box>,
766 )
767
768 return (
769 <Box flexDirection="column" gap={1}>
770 {latest && (
771 <Box flexDirection="row" columnGap={2}>
772 <Text color="suggestion">
773 Panel {latest} is out: run {UPDATE_COMMAND}, then restart Claude Code.
774 </Text>
775 {btn('dismiss-update', 'Dismiss', 'u', () => update($, newVersion, () => null))}
776 </Box>
777 )}
778 {tabBar}
779 {working && <Text color="warning">⟳ Running {working}...</Text>}
780 {current === 'panel' && panelTab}
781 {current === 'answer' && answerTab}
782 {current === 'keys' && keysTab}
783 </Box>
784 )
785 })
786}
787types/index.d.ts 44 lines1export type PanelStatus = {
2 root: string | null
3 mod: string | null
4 isPMounted: boolean
5 isDiagRunning: boolean
6 checkedAt: number
7}
8
9export type PanelRun = {
10 action: string
11 ok: boolean
12 outcome: 'done' | 'failed' | 'nothing found'
13 detail: string
14 // The ask field's plain-English answer, in markdown; absent when the model gave none.
15 answer?: string
16 // The question an ask or deep dive answered, for Deep dive and Continue in chat.
17 question?: string
18} | null
19
20// The ask card's picks: citations to read, lines per excerpt, words in the answer.
21export type AskSettings = { topK: string; excerptLines: string; words: string }
22
23// The pane's tabs: the buttons, the last vanilla answer, and the API keys.
24export type PanelTab = 'panel' | 'answer' | 'keys'
25
26// Where each key the Keys tab knows comes from, and its last four characters.
27// The keys themselves never go in the session state, only in the panel's store.
28export type KeyStatus = Record<string, { source: 'panel' | 'environment'; tail: string } | null>
29
30declare module 'claude-code' {
31 interface PluginState {
32 'agentic-z-panel': {
33 status: PanelStatus | null
34 lastRun: PanelRun
35 busy: string | null
36 newVersion: string | null
37 askSettings: AskSettings
38 lastAnswer: PanelRun
39 tab: PanelTab
40 keys: KeyStatus
41 }
42 }
43}
44