SLOPSHOPPER

agentic-z-panel

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…

newpanebandcommandtoastmodel
★ 19v0.8.0NOASSERTIONupdated 2026-10-26DayZ-n-Chill/Agentic-Z/mods/agentic-z-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agentic-z-panel
│ ┃ Agentic-Z ✕ › fix the failing auth test and add an audit log call │ ┃ 1: ▸ Panel 2: Answer 3: Keys │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭─────────────────────────────────────────── ⎿ Read 6 lines │ ┃ │ Status ⏺ Update(src/auth.ts) │ ┃ │ ● P: drive: not mounted r: Re ⎿ Added 2 lines, removed 1 line │ ┃ │ ● Active mod: none (run /dayz-init) ⏺ Bash(bun test) │ ┃ │ ● Test session: stopped ⎿ 3 pass, 1 fail │ ┃ │ Agentic-Z not found here. │ ┃ ╰─────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ╭─────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ │ Start a project │ ┃ │ › /dayz-panel │ ┃ │ Setup e: Check setup v: Add test server ⎿ agentic-z-panel: Agentic-Z panel opened. │ ┃ │ ⎿ agentic-z-panel: P: drive not mounted (press p in the pan │ ┃ │ New mod:: MyVest (first mod: MyVest by You ⎿ agentic-z-panel: Active mod none (run /dayz-init) │ ┃ │ ⎿ agentic-z-panel: Test session stopped │ ┃ │ Import mod:: folder path or GitHub link ⏎ ⎿ agentic-z-panel: Agentic-Z not found in this project or th │ ┃ ╰─────────────────────────────────────────── │ ┃ │ ┃ ╭─────────────────────────────────────────── │ ┃ │ Build and test │ ┃ │ │ ┃ │ Build b: Build PBO c: Check build d: D │ ┃ │ Agentic-Z panel ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Agentic-Z panel ⟨Claude Code's own drawing⟩
Pane · Agentic-Z
1: ▸ Panel 2: Answer 3: Keys ╭──────────────────────────────────────────────────────────╮ │ Status │ │ ● P: drive: not mounted r: Refresh │ │ ● Active mod: none (run /dayz-init) │ │ ● Test session: stopped │ │ Agentic-Z not found here. │ ╰──────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────── │ Start a project │ │ Setup e: Check setup v: Add test server (chernarus) │ │ New mod:: MyVest (first mod: MyVest by YourName) ⏎ create │ │ Import mod:: folder path or GitHub link ⏎ import ╰─────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────╮ │ Build and test │ │ │ │ Build b: Build PBO c: Check build d: Deploy prebuilt │ │ │ │ Play l: Launch g: Check in game s: Stop t: Logs │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Tools │ │ │ │ Open p: Mount P: w: Workbench o: Object Builder │ │ │ │ Ship h: Health page k: Release pack │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Ask vanilla DayZ │ │ Question:: how does food spoil ⏎ ask │ │ │ │ Sources:: 10 ▾ Excerpt:: 60 lines ▾ Answer:: Normal ▾ │ │ │ │ The answer opens on the Answer tab. │ ╰──────────────────────────────────────────────────────────╯
README

agentic-z-panel

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.

Install

/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.

What it shows

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.

Tabs

Three tabs sit at the top of the panel; click one or press 1, 2 or 3.

  • Panel (1): the status, the buttons, the ask field and the last run.
  • Answer (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.
  • Keys (3): the API keys the Agentic-Z tools read, each with a field to paste a new key and Clear for one set here.
KeyUsed by
VOYAGE_API_KEYvanilla source search: the ask field, /dayz-ask and the dayz-rag tools
TYPESAFE_API_KEYJev, the routing model /dayz-route asks
GITHUB_TOKENa 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.

Updates

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.

Buttons

ButtonKeyRuns
Check setupedayz-preflight/preflight.py
Add test servervdayz-add-server/add_server.py chernarus
New mod (a text field)Enterdayz-new-mod/new_mod.py <name>; type MyVest by YourName for a first mod, to set the author
Import mod (a text field)Enterdayz-import-mod/import_mod.py --source <path or GitHub link>
Mount P:pdayz-workdrive/mount.py
Build PBObdayz-build-pbo/build.py <active mod>
Check buildcdayz-gate/gate.py build <active mod>
Check in gamegdayz-gate/gate.py ingame <active mod>
Deploy prebuiltddayz-deploy-pbo/deploy.py <active mod>
Launchldayz-launch-test/launch.py <active mod>
Stopsdayz-stop-test/stop_test.bat
Workbenchwdayz-launch-workbench/launch.py --mod <active mod>
Object Builderodayz-launch-objectbuilder/launch.py
Logstdayz-logwatch/logwatch.py --since 30 --duration 3 --mod <active mod>
Health pagehdayz-health/health.py <active mod>
Release packkdayz-release-pack/release_pack.py <active mod>
Ask vanilla (a text field)Enterdayz-ask/ask.py --top-k <Sources> --excerpt-lines <Excerpt> "<question>"
Deep dive (under an answer)vdayz-ask/ask.py --top-k 25 --excerpt-lines 200 "<question>", then a longer answer
Continue in chat (under an answer)mputs the question and answer in the prompt box for the main conversation
Refreshrre-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".

Where it finds Agentic-Z

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).

Tests

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.

Source 2 files
hooks/register.tsx 787 lines
1import { 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}
787
types/index.d.ts 44 lines
1export 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