SLOPSHOPPER

chief-of-staff

A Chief of Staff for your Claude Code sessions, in the desktop app or a terminal: one session becomes the Chief, watches every other session, tells you which…

guardtoastprompttoolprocessnetwork
A shopper browsing a rack in a slop shop
README

Chief of Staff

Version 0.3.1.

A chief of staff for your Claude Code sessions, in the desktop app or a terminal. One session becomes the Chief. It watches every other session, tells you which ones need you, and closes or answers them when you ask, by typing or by voice. A local console shows a live graph of your sessions, a roster, the Chief's chat, and the full transcript of any session with a reply box.

Install

In a terminal session:

/plugin install chief-of-staff --marketplace jay-stacey/claude-plugins

Answer y to add the marketplace, then pick the user scope. With the personal-plugins marketplace already added, /plugin install chief-of-staff@personal-plugins does the same. The mod then loads in your desktop sessions too.

Then run /chief-setup in the session that will be the Chief. It walks through the rest, one step at a time:

  1. It checks the machine: Windows, Python 3.10 or later for the server, and your GPU.
  2. It installs the local voice with server/setup.ps1 in the background and reports each step. This is optional and takes 10 to 30 minutes.
  3. It offers to enroll your wake word: you say it five times so the Chief learns your voice.
  4. It shows the permission rule that lets the Chief act on sessions when you ask by voice or from the console, and writes it to the folder's personal .claude/settings.local.json only after you say yes.
  5. It has you run /chief once and checks that the server, the Chief and the voice are healthy.

/chief-setup check, voice, jev, enroll or permissions runs one step on its own. /chief-setup actions explains why the Chief cannot archive, stop or message a session, and changes nothing.

Use

Type /chief in a Claude Code session (desktop app or terminal). That session becomes the Chief: the server starts, the console opens in your browser, and the Chief tells you what needs you.

  • Ask it things like "what needs me", "how is DP1-676 doing", "close the DP1-676 session" or "tell the spec review session to go ahead".
  • It archives a session when you ask. If that session is working right now, it says so and asks first. It never deletes a session.
  • A reply you dictate for another session is read back to you before it is sent.
  • Ask for real work, like "fix the login bug in the API", and the Chief starts a new session for it, on the model Jev suggests.
  • It speaks up on its own when a session starts waiting on you, finishes, or goes stale (not in quiet hours).
  • /chief status says whether the server runs. /chief stop stops the server and the voice.
  • /chief in another session is refused while the Chief is live. That session tells you which session is the Chief. To move the Chief, archive its session, then run /chief in the new one. A terminal Chief: type /chief stop there or close it. The role frees about a minute later.

What the Chief can do from where

SessionFrom a desktop ChiefFrom a terminal Chief
Desktop appmessage, archive (asks first if working), open, startmessage when the session can be reached, else it opens in the app for you to reply there; archive opens it in the app for you to archive there; open
Terminalmessagemessage
Backgroundmessage, stop (asks first if working)message, stop (asks first if working), start new ones

A background session is stopped, never archived. Its conversation is kept, so claude --bg --resume brings it back. A terminal session is closed in its own window. Nothing ever deletes a session, removes a background session, or ends a terminal session's process.

Only one Chief runs on a computer, and it never lists itself among your sessions. The console stays at http://127.0.0.1:47870 (or the next free port); /chief opens it again.

Voice

The voice is local: your PC's microphone and speakers, a "Hey Chief" wake word, speech to text and text to speech on the GPU with a CPU fallback. Nothing is sent to a cloud service. It is optional and needs a one-time install into its own Python environment (%USERPROFILE%\.claude\chief-of-staff\venv); see server/setup.ps1. Without it the console and the typed chat work as normal and the console shows the voice as not installed.

The voice-replies plugin (1.7.0 or later) stays silent in the Chief's session, so you never hear a reply twice.

Jev

Jev is a quick router that runs before the Chief acts. For each console or voice request it suggests whether the Chief handles it or starts a new session, which model that session uses, whether it is background work, and about how long it takes. The console shows the suggestion under your message. Your words always win: say "use opus" or "do it yourself" to overrule it.

Jev answers from fixed rules at once. It can also ask a model, within 800 ms:

  • Local (recommended when your PC can run it): a free, open-source Qwen3.5 model (Apache-2.0) on your own PC. No key, and nothing leaves your PC.
  • Hosted: Claude Haiku through the Anthropic API with your own key, a Groq key with zero data retention on, or your own server.
  • Rules only: no model.

/chief-setup checks your PC and offers the choice. It asks before any download, checks every model file against a pinned fingerprint, and stores a hosted key in Windows Credential Manager, never in a file. To run the steps by hand, use python server/jev_setup.py scan.

Settings

Set them with /plugin configure chief-of-staff@personal-plugins.

SettingDefaultWhat it does
stale_hours24A session with no activity this long counts as stale
recent_days3The graph and roster show live sessions and sessions active in this many days
speak_repliestrueSpeak the Chief's replies
listenwakewake (always listening for the wake word), ptt (push to talk) or off
voiceautoauto uses the voice setup picked for this PC (Chatterbox on a GPU, Kokoro on a CPU); chatterbox or kokoro forces one, and the other is the fallback
chief_nameChiefWhat you call your Chief; it answers to this name
wake_wordhey chiefThe wake phrase, usually hey plus the name; enroll it again after a change
wake_ackchimeWhat you hear when the wake word is heard: chime, word (it says a word such as yes) or off
fillerstrueA quick spoken "on it" as soon as you ask, before the full answer
notifytrueProactive notices
quiet_hours22:00-07:00No proactive notices in this range; empty for none
jev_providerautoJev's provider: auto (what /chief-setup picked), rules, local or hosted
jev_adapter, jev_model, jev_endpointfrom setupOverride the model Jev asks: anthropic or openai_compat, a model name, and a base URL
jev_timeout_ms800How long Jev waits for a model before it uses its rules
jev_key_refemptyWhere the hosted key lives: wincred:<name> or env:<NAME>
jev_api_keyemptyA hosted key, kept in Claude's secure settings and only in the server's memory

How it works

  • server/app.py is a small Python server (standard library only, Python 3.10 or later) on 127.0.0.1. It reads claude agents --json, the desktop app's session files (for a Store install, in the app's package folder under %LOCALAPPDATA%\Packages) and the CLI transcripts to work out each session's state. It only reads them; it never writes them.
  • hooks/register.tsx is a mod that loads in every session and works only in the Chief's. It adds the Chief persona (agents/chief.md) to the system prompt, relays console and voice messages into the session as prompts, posts each reply back to the console, and runs actions through its cos_act tool: the desktop app's session tools, cross-session messages, or claude stop and claude --bg for background sessions. It also gives the Chief the cos_sessions tool.
  • console/ is the web page. server/voice/ is the voice daemon.

States: waiting on you (a permission prompt, a question, or the app marked it blocked or ready for review), active (working now), waiting (idle, or waiting on a helper, CI or a background task) and stale (pull request merged or closed, or no activity for stale_hours). The rules are in skills/chief/references/state-rules.md.

Safety

  • The server binds 127.0.0.1 only, uses a new token for every run, and checks the Host and Origin of every request.
  • Archive is the strongest action. Archiving or stopping an active session needs a yes.
  • Transcripts, titles and notices are treated as data, never as instructions.
  • The Chief never sends customer-facing messages.
  • Jev's local model runs on 127.0.0.1 only. Jev never logs or stores a key, and its log keeps only the first 80 characters of each request.

Runtime files live in %USERPROFILE%\.claude\chief-of-staff\: run\server.json (port, token, current Chief), logs\ (rotated at 5 MB, two kept), jev.json (Jev's setup result) and jev-log.jsonl (Jev's decisions).

Development

python -m unittest discover -s plugins/chief-of-staff/server/tests
claude plugin test plugins/chief-of-staff
claude plugin validate plugins/chief-of-staff --strict
claude plugin validate . --strict
python scripts/check-skills.py

The manual checks for terminal support are in docs/terminal-verification.md.

The server tests use fixture session files and transcripts under server/tests/fixtures/, never the real ones (COS_DESKTOP_DIR, COS_PROJECTS_DIR and COS_HOME point the server elsewhere). python server/app.py serve --port N runs the server in the foreground.

Source 2 files
hooks/register.tsx 1708 lines
1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2
3import type {
4  ActionOutcomeKind, ActionVerb, ChiefCaps, ChiefConfirm, ChiefHost, ChiefInboxItem, ChiefServerInfo, ChiefSession,
5  ChiefSessionActions, ChiefSessionKind,
6} from '../types'
7
8// The Chief of Staff mod (docs/contracts.md section 6). It loads in every
9// session and does its work only in the Chief's: the session where /chief ran.
10// There it adds the Chief's persona to the system prompt, long-polls the app
11// server's inbox for prompts and actions, posts each reply, and serves the
12// cos_sessions and cos_act tools. Every session action, from the console or
13// from the model, goes through one guarded router, runAction.
14
15const STORE_CHIEF = 'chief:session'
16const TOOL = 'cos_sessions'
17const TOOL_FULL = 'mcp__chief-of-staff__cos_sessions'
18const ACT_TOOL = 'cos_act'
19const ACT_TOOL_FULL = 'mcp__chief-of-staff__cos_act'
20const SESSIONS_SERVER = 'ccd_session_mgmt'
21const PERSONA_ID = 'chief-of-staff:persona'
22const POLL_WAIT_S = 25
23// A pause between long polls, so a server that answers at once never spins the loop.
24const POLL_GAP_MS = 250
25const BACKOFF_FIRST_MS = 2000
26const BACKOFF_MAX_MS = 30000
27// /chief from the prompt and the skill's own load both start the Chief; one is enough.
28const BECOME_DEDUPE_MS = 15000
29const START_TIMEOUT_MS = 30000
30const CHIEF_COMMAND = /^\/(?:chief-of-staff:)?chief(?:\s|$)/
31const CONFIRM_LINE = /^\s*\[\[\s*confirm\s*:\s*([^|\]]+?)\s*\|\s*([^\]]+?)\s*\]\]\s*$/i
32const ARCHIVE_TOOL = 'mcp__ccd_session_mgmt__archive_session'
33const SEND_TOOL = 'mcp__ccd_session_mgmt__send_message'
34const START_TOOL = 'mcp__ccd_session__start_session'
35// A yes, or an action Jay confirmed in the console, stays approved this long.
36const APPROVAL_MS = 120000
37// A hand-off to the Chief's own tools is settled by its own call within this time.
38const HANDOFF_MS = 300000
39const STOP_TIMEOUT_MS = 30000
40const BG_START_TIMEOUT_MS = 10000
41const AGENTS_TIMEOUT_MS = 5000
42const RECENT_START_MS = 120000
43const RESULT_RETRY_MS = [1000, 4000, 15000]
44const DONE_ITEM_MS = 600000
45const HELLO_TRIES = 3
46const HELLO_RETRY_MS = 1000
47const MAX_DATA_TITLE = 80
48const MAX_START_TEXT = 4000
49// A background id or a start name that claude takes as one argument: never read as a flag.
50const ARG_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/
51// The server's session id pattern.
52const SESSION_ID = /^(local|cli|bg)_[A-Za-z0-9-]{1,80}$/
53const START_MODELS = new Set(['haiku', 'sonnet', 'opus'])
54// Control and direction characters, quotes and brackets: what a title must not bring into a prompt.
55const UNSAFE_TITLE = /[\u0000-\u001f\u007f​-‏‪-‮⁦-⁩"'`‘’“”[\]{}<>()]/g
56const YES_WORDS = /^(yes|yeah|yep|yup|sure|ok|okay|do it|go ahead|please do|confirm|confirmed|archive)\b/
57const NO_WORDS = /\b(no|nope|not|don't|dont|do not|wait|stop|leave|cancel|keep)\b/
58// Prompts from the server, not answers from Jay.
59const SERVER_PROMPT = /^\[(Notice|Action)\b/
60const NOTICE_PROMPT = /^\[Notice\b/
61// A hint, not a wall (plan D9): it steers a model that reaches for the shell back to cos_act.
62// runsKillCommand applies it to the parts of a command that run, not to quoted text.
63const KILL_COMMAND = /\bclaude(?:\.exe|\.cmd)?["']?(?:\s+--?[\w-]+(?:[= ][^\s]+)?)*\s+(?:stop|kill|rm|respawn)\b|\b(?:taskkill|stop-process|spps|pkill|killall|tskill)\b|\.kill\s*\(|\bwmic\b[^\n]*\bprocess\b|(?:^|[;&|(]\s*)kill\s+-?\w/i
64const SHELL_REFUSAL = 'The Chief does not stop, remove or kill sessions or processes from the shell. Use the ' +
65  'cos_act tool: it checks whether the session is working and asks Jay first.'
66const SHELL_UNCHECKED = 'The Chief of Staff could not check that command, so it did not run.'
67const OUT_OF_DATE = 'The Chief of Staff server is out of date. Type /chief to update it.'
68const NOT_CHECKED = 'The Chief of Staff could not check that session, so nothing was done.'
69const SETTINGS_BLOCK = 'Your settings block this, so nothing was done.'
70const SETTINGS_UNCHECKED = 'The Chief of Staff could not check your settings, so nothing was done.'
71const NOT_JAY = 'Only Jay can ask the Chief to act. Another session asked for this, so nothing was done.'
72const MIXED_TURN = "Another session's message arrived in the same turn, so I did not act. Ask me again."
73// Kinds of prompt or delivery that come from another session: they never count as Jay.
74const PEER_KINDS = new Set(['peer', 'peer-send-message', 'unclassified'])
75
76// Module state. A reload starts it over; session.start then restores the role
77// from the store, so a Chief stays the Chief across reloads.
78let myId = ''
79let localId: string | null = null
80let isChief = false
81let persona = ''
82let loopGen = 0
83let backoffMs = 0
84let server: ChiefServerInfo | null = null
85let becameAt = -Infinity
86let becoming: Promise<void> | null = null
87let options: PluginOptions = {}
88// True once a prompt.compose hook ran here, so the persona is in the system prompt.
89let composeSeen = false
90// Set by `/chief stop` or `/chief status`, so the skill's own load does not claim the role.
91let skipSkillClaimUntil = -Infinity
92// The last refused claim: /chief from the prompt and the skill's own load get the same answer.
93let refused: { at: number; text: string } | null = null
94// What this session can do, and where it draws (plan 5.1).
95let host: ChiefHost | null = null
96let startSurface: ChiefHost['surface'] = null
97
98// Who counts as Jay (plan 5.9). Each prompt since the last turn start, with its trust level;
99// the trust of the running main turn; whether Jay ever typed here.
100type Level = 'typed' | 'press' | 'none'
101let originsSinceTurn: { kind: string; level: Level }[] = []
102let turnTrust: 'ok' | 'mixed' | 'none' = 'none'
103let isTurnRunning = false
104let hasTypedOrigin = false
105
106// The guard's memory (plan 5.7). `discussed`: sessions the Chief looked up or was refused in
107// the current turn. `pendingAsks`: the session|verb keys a refusal asked Jay about since the last
108// reply. `asked`: the last reply's confirm question and its keys. `approvals`: what Jay's yes, or a
109// console confirm, approved, by session|verb, until used once or expired.
110type Approval = { until: number; source: 'typed' | 'press'; isFromYes: boolean }
111let discussed = new Set<string>()
112const pendingAsks = new Set<string>()
113let asked: { keys: string[]; yes: string; no: string } | null = null
114const approvals = new Map<string, Approval>()
115// Hand-offs to the Chief's own tools, settled by its own call (plan 6.4).
116const handoffs = new Map<string, { verb: ActionVerb; sessionId: string; until: number }>()
117// Inbox items handled once (plan 5.10), and starts in flight or just made, by tag.
118const doneItems = new Map<string, { body: Record<string, unknown>; at: number }>()
119const runningItems = new Set<string>()
120const recentStarts = new Map<string, number>()
121// The console or voice prompt Jev advised on, until the Chief starts a session for it or
122// its turn ends: what the Chief really did goes to Jev's log. A prompt that arrives while
123// another turn runs may be scored on that turn; the log is a measure, not a control.
124let jevPending: string | null = null
125const LOG_KEEP = 200
126const logLines: string[] = []
127
128type ApiResult = { status: number; ok: boolean; json: Record<string, unknown> | null }
129type Origin = 'console' | 'model'
130type ActionRequest = {
131  verb: ActionVerb
132  id?: string
133  text?: string
134  reason?: string
135  confirmed?: boolean
136  cwd?: string
137  title?: string
138  model?: string
139  origin: Origin
140}
141type ActionOutcome = { outcome: ActionOutcomeKind; detail: string; needsJay?: string }
142type SessionAction = keyof ChiefSessionActions
143
144export const register: Register = (on, opts) => {
145  options = opts ?? {}
146
147  on('session.start', async ($, e, next) => {
148    const surface = (e as { surface?: unknown }).surface
149    if (surface === 'terminal' || surface === 'desktop' || surface === 'vscode' || surface === 'mobile') {
150      startSurface = surface
151    }
152    const result = await next(e)
153    try {
154      myId = await $.session.id()
155      log($, `loaded in session ${myId}`)
156      // Deferred everywhere else, so it costs other sessions nothing until asked for.
157      await registerTool($, false)
158      if ((await $.store.get(STORE_CHIEF)) === myId) {
159        isChief = true
160        persona = await loadPersona($)
161        $.clock.after(0, () => void become($, false))
162      }
163    } catch (err) {
164      log($, `session.start: ${String(err)}`)
165    }
166    return result
167  })
168
169  // /chief makes this session the Chief. The persona and the tool are in place
170  // before the turn starts, so the /chief turn itself already has them.
171  on('prompt.submit', async ($, e, next) => {
172    try {
173      const typed = e.text.trim()
174      const origin = (e as { origin?: { kind?: unknown; name?: unknown } }).origin
175      const kind = typeof origin?.kind === 'string' ? origin.kind : 'unclassified'
176      const isOwn = kind === 'plugin' && origin?.name === 'chief-of-staff'
177      const level = levelOf(kind, isOwn, typed)
178      if (kind === 'composer') hasTypedOrigin = true
179      noteOrigin(isOwn && NOTICE_PROMPT.test(typed) ? 'notice' : kind, level,
180        e.turnId !== undefined || isTurnRunning)
181      if (isChief && level !== 'none' && !isOwn && !SERVER_PROMPT.test(typed) && !CHIEF_COMMAND.test(typed)) {
182        await noteAnswer($, typed, level)
183      }
184      if (CHIEF_COMMAND.test(typed)) {
185        // `/chief stop` and `/chief status` only manage the server; they make no Chief.
186        const word = typed.split(/\s+/)[1]?.toLowerCase() ?? ''
187        if (word === 'stop' || word === 'status') {
188          skipSkillClaimUntil = (await now($)) + BECOME_DEDUPE_MS
189          return next(e)
190        }
191        const refusal = await claim($)
192        if (refusal !== null) return next({ ...e, context: [...(e.context ?? []), refusal] })
193        // Where prompt.compose does not reach this plugin (an organization's managed
194        // settings), the persona rides along with the /chief prompt instead.
195        if (!composeSeen && persona !== '') {
196          const context = `Chief of Staff persona for this session:\n\n${persona}`
197          return next({ ...e, context: [...(e.context ?? []), context] })
198        }
199      }
200    } catch (err) {
201      log($, `prompt.submit: ${String(err)}`)
202    }
203    return next(e)
204  })
205
206  // Another session's message arriving while Jay's turn runs taints that turn (plan 5.9).
207  // The Chief's own subagents (agentId), coordinator messages and task notices are neutral.
208  on('session.receive', async ($, e, next) => {
209    try {
210      if (e.agentId === undefined && PEER_KINDS.has(e.origin.kind)) noteOrigin(e.origin.kind, 'none', isTurnRunning)
211    } catch (err) {
212      log($, `session.receive: ${String(err)}`)
213    }
214    return next(e)
215  })
216
217  on('skill.prompt', async ($, e, next) => {
218    try {
219      const isChiefSkill = e.skill === 'chief' || e.skill === 'chief-of-staff:chief'
220      log($, `skill.prompt ${e.skill}${isChiefSkill ? ' (the Chief skill)' : ''}`)
221      if (isChiefSkill && (await now($)) >= skipSkillClaimUntil) {
222        // Refused: the skill's own steps would start the server and act as the Chief.
223        const refusal = await claim($)
224        if (refusal !== null) return next({ ...e, text: refusal })
225      }
226    } catch (err) {
227      log($, `skill.prompt: ${String(err)}`)
228    }
229    return next(e)
230  })
231
232  // The model can run /chief through the Skill tool. Once the skill is loaded in a
233  // session the engine does not expand it again, so skill.prompt does not fire;
234  // the tool call always does. claim() drops a second call within 15 s.
235  on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
236    try {
237      const call = e as unknown as { skill?: unknown; args?: unknown }
238      const isChiefSkill = call.skill === 'chief' || call.skill === 'chief-of-staff:chief'
239      const word = typeof call.args === 'string' ? call.args.trim().split(/\s+/)[0]?.toLowerCase() ?? '' : ''
240      if (isChiefSkill && word !== 'stop' && word !== 'status') {
241        log($, `Skill tool call ${String(call.skill)}`)
242        const refusal = await claim($)
243        if (refusal !== null) return { deny: refusal }
244      }
245    } catch (err) {
246      log($, `Skill tool call: ${String(err)}`)
247    }
248    return next(e)
249  })
250
251  on('prompt.compose', async ($, e, next) => {
252    composeSeen = true
253    const result = await next(e)
254    if (!isChief || persona === '') return result
255    const sections = result.sections.filter(s => s.id !== PERSONA_ID)
256    return { ...result, sections: [...sections, { id: PERSONA_ID, text: persona, scope: 'session' as const }] }
257  })
258
259  on('turn.start', async ($, e, next) => {
260    const result = await next(e)
261    if (e.agentId === undefined) {
262      turnTrust = trustOf(originsSinceTurn)
263      originsSinceTurn = []
264      isTurnRunning = true
265      if (isChief) {
266        discussed = new Set()
267        $.clock.after(0, () => void post($, '/api/mod/busy', { busy: true, cliId: myId }))
268      }
269    }
270    return result
271  })
272
273  on('turn.complete', async ($, e, next) => {
274    const result = await next(e)
275    if (e.agentId !== undefined) return result
276    isTurnRunning = false
277    if (!isChief) return result
278    const answer = e.reason === 'answer' && !e.isAborted ? e.answer : ''
279    const turnId = e.turnId
280    if (jevPending !== null) {
281      const id = jevPending
282      jevPending = null
283      $.clock.after(0, () => void post($, '/api/mod/jev-outcome', { id, route: 'chief', model: null }))
284    }
285    if (answer.trim() !== '') {
286      // The reply's confirm question, if any, is what Jay's next prompt answers, and it is
287      // about what the Chief was refused since its last reply. A reply without one (a notice
288      // in between) leaves the open question alone; Jay's next own message answers or clears it.
289      const { confirm } = splitConfirm(answer)
290      if (confirm !== null) asked = { keys: [...pendingAsks], yes: confirm.yes, no: confirm.no }
291      pendingAsks.clear()
292    }
293    $.clock.after(0, () => void sendReply($, answer, turnId))
294    return result
295  })
296
297  on('tool.call', { tool: TOOL_FULL }, async ($, e) => {
298    const input = e as unknown as { id?: unknown; all?: unknown }
299    const id = typeof input.id === 'string' && input.id.trim() !== '' ? input.id.trim() : undefined
300    if (id !== undefined) {
301      discussed.add(id)
302      // As in 0.2.2: a confirm question after looking a desktop session up is about archiving it.
303      if (isChief) pendingAsks.add(keyOf(id, 'archive'))
304    }
305    return { result: await sessionsForModel($, id, input.all === true) }
306  }).catch(() => ({
307    result: JSON.stringify({ error: 'The Chief of Staff tool failed. Ask Jay to run /chief again.' }),
308  }))
309
310  // The one guarded road for the model: archive, stop, reply, open and start.
311  on('tool.call', { tool: ACT_TOOL_FULL }, async ($, e) => {
312    return { result: JSON.stringify(await actForModel($, e as unknown as Record<string, unknown>)) }
313  }).catch(() => ({
314    result: JSON.stringify({ ok: false, outcome: 'failed', detail: 'The Chief of Staff tool failed, so nothing was done.' }),
315  }))
316
317  // A session the Chief starts for a console or voice prompt is what it did with Jev's advice.
318  on('tool.call', { tool: START_TOOL }, async ($, e, next) => {
319    if (isChief && jevPending !== null) {
320      const input = e as unknown as { model?: unknown }
321      const id = jevPending
322      jevPending = null
323      await post($, '/api/mod/jev-outcome', {
324        id, route: 'new_session', model: typeof input.model === 'string' ? input.model : null,
325      })
326    }
327    return next(e)
328  })
329
330  // The archive guard: in the Chief's session, archiving a session that is working, or
331  // whose helper session is, needs Jay's yes first. Only Jay's turns may archive.
332  on('tool.call', { tool: ARCHIVE_TOOL }, async ($, e, next) => {
333    if (!isChief) return next(e)
334    const input = e as unknown as { session_id?: unknown }
335    const id = typeof input.session_id === 'string' ? input.session_id.trim() : ''
336    const distrust = trustRefusal()
337    if (distrust !== null) {
338      logAct($, 'archive', id, turnTrust === 'mixed' ? 'mixed' : 'peer', 'refused', distrust)
339      return { deny: distrust }
340    }
341    const { refusal, session } = await actionRefusal($, 'archive', id, await peekApproval($, id, 'archive'), true)
342    if (refusal !== null) return { deny: refusal }
343    if (session === null) {
344      logAct($, 'archive', id, 'model', 'allowed404', 'The server did not know the session; the app decides.')
345    } else if (kindOf(session) !== 'desktop') {
346      return { deny: 'Use cos_act for terminal and background sessions.' }
347    }
348    useApproval(id, 'archive')
349    const result = await next(e)
350    await settleHandoff($, 'archive', id, result)
351    return result
352  }).catch(($, e, next) => (next.called
353    ? next(e)
354    : { deny: 'The Chief of Staff could not check that session. Ask Jay to confirm first, then try again.' }))
355
356  // The Chief's own desktop send: only in Jay's turns, and it settles a reply hand-off.
357  on('tool.call', { tool: SEND_TOOL }, async ($, e, next) => {
358    if (!isChief) return next(e)
359    const input = e as unknown as { session_id?: unknown }
360    const id = typeof input.session_id === 'string' ? input.session_id.trim() : ''
361    const distrust = trustRefusal()
362    if (distrust !== null) {
363      logAct($, 'reply', id, turnTrust === 'mixed' ? 'mixed' : 'peer', 'refused', distrust)
364      return { deny: distrust }
365    }
366    const result = await next(e)
367    await settleHandoff($, 'reply', id, result)
368    return result
369  }).catch(($, e, next) => (next.called
370    ? next(e)
371    : { deny: 'The Chief of Staff could not check that send, so it did not go.' }))
372
373  // The Chief's shell does not stop, remove or kill sessions (plan 5.6). A hint only.
374  on('tool.call', { tool: 'Bash' }, async ($, e, next) => (isShellAllowed(e.command) ? next(e) : { deny: SHELL_REFUSAL }))
375    .catch(($, e, next) => (next.called ? next(e) : { deny: SHELL_UNCHECKED }))
376  on('tool.call', { tool: 'PowerShell' }, async ($, e, next) => (isShellAllowed(e.command) ? next(e) : { deny: SHELL_REFUSAL }))
377    .catch(($, e, next) => (next.called ? next(e) : { deny: SHELL_UNCHECKED }))
378}
379
380function isShellAllowed(command: unknown): boolean {
381  return !isChief || !runsKillCommand(String(command ?? ''))
382}
383
384// Programs that run text they are given, so text near them can be a command.
385const INTERPRETER = /\b(?:bash|sh|zsh|dash|pwsh|powershell|cmd|iex|invoke-expression|eval|xargs|start-process|python[\d.]*|node|ruby|perl|source|wsl)\b/i
386
387// Whether the command would run a stop, remove or kill, not merely mention one: text in a
388// quoted string, a heredoc, a here-string or a comment is data unless an interpreter (bash -c,
389// powershell -Command, iex, a pipe into bash) or a $( ) runs it.
390export function runsKillCommand(command: string): boolean {
391  const text = withoutDataBodies(command)
392  return commandSegments(text).some(seg => KILL_COMMAND.test(INTERPRETER.test(seg.blank) ? seg.raw : seg.blank))
393}
394
395// Drops the body of a heredoc (<<EOF) or PowerShell here-string (@' ... '@), unless the line
396// that opens it names an interpreter that would run the body.
397function withoutDataBodies(command: string): string {
398  const lines = command.split(/\r?\n/)
399  const out: string[] = []
400  for (let i = 0; i < lines.length; i++) {
401    const line = lines[i]!
402    out.push(line)
403    const heredoc = /<<-?\s*(['"]?)([A-Za-z_][\w-]*)\1/.exec(line)
404    const hereString = /@(['"])\s*$/.exec(line)
405    if (heredoc === null && hereString === null) continue
406    const isEnd = (l: string) => (heredoc !== null ? l.trim() === heredoc[2] : l.startsWith(`${hereString![1]}@`))
407    let end = i + 1
408    while (end < lines.length && !isEnd(lines[end]!)) end++
409    if (!INTERPRETER.test(line)) {
410      i = Math.min(end, lines.length - 1)
411      if (end < lines.length) out.push(lines[end]!)
412    }
413  }
414  return out.join('\n')
415}
416
417// Splits a command at ; & | ( ) and newlines outside quotes, and drops # comments. Each part has
418// its text as written (raw) and with quoted strings emptied (blank). A quoted string stays when
419// it is the command's own name, or a double-quoted string holds $( ) or a backtick.
420function commandSegments(command: string): { raw: string; blank: string }[] {
421  const segments: { raw: string; blank: string }[] = []
422  let raw = ''
423  let blank = ''
424  const flush = () => {
425    if (blank.trim() !== '' || raw.trim() !== '') segments.push({ raw, blank })
426    raw = ''
427    blank = ''
428  }
429  let quote: string | null = null
430  let held = ''
431  let atStart = false
432  const close = (isOpen: boolean) => {
433    const q = quote!
434    const keep = atStart || isOpen || (q === '"' && /\$\(|`/.test(held))
435    raw += q + held + (isOpen ? '' : q)
436    blank += keep ? q + held + (isOpen ? '' : q) : q + q
437    quote = null
438  }
439  for (let i = 0; i < command.length; i++) {
440    const c = command[i]!
441    if (quote === null) {
442      if (c === "'" || c === '"') {
443        quote = c
444        held = ''
445        atStart = blank.trim() === ''
446      } else if (c === '#' && (blank === '' || /\s$/.test(blank))) {
447        while (i + 1 < command.length && command[i + 1] !== '\n') i++
448      } else if (/[;&|\n\r()]/.test(c)) {
449        flush()
450      } else {
451        raw += c
452        blank += c
453      }
454    } else if (c === quote) {
455      close(false)
456    } else if (c === '\\' && quote === '"' && command[i + 1] === '"') {
457      held += c + '"'
458      i++
459    } else {
460      held += c
461    }
462  }
463  if (quote !== null) close(true)
464  flush()
465  return segments
466}
467
468// ------------------------------------------------------- who counts as Jay ---
469
470// TYPED: Jay typed it here. PRESS: the console, the voice, Remote Control or the desktop
471// host's own turns; it passes the busy guard only. NONE: anything else (plan 5.9).
472function levelOf(kind: string, isOwn: boolean, text: string): Level {
473  if (kind === 'composer') return 'typed'
474  if (kind === 'bridge' || kind === 'sdk') return 'press'
475  // The mod's own prompts: a console hand-off may run tools; a notice never may.
476  if (isOwn) return NOTICE_PROMPT.test(text) ? 'none' : 'press'
477  return 'none'
478}
479
480// One prompt or delivery. Inside a running turn, another session's taints that turn; one
481// for the next turn joins the set the next turn start reads.
482function noteOrigin(kind: string, level: Level, isInTurn: boolean): void {
483  if (isInTurn && level === 'none') {
484    if (PEER_KINDS.has(kind) && turnTrust === 'ok') turnTrust = 'mixed'
485    return
486  }
487  originsSinceTurn.push({ kind, level })
488}
489
490function trustOf(origins: { level: Level }[]): 'ok' | 'mixed' | 'none' {
491  if (origins.length === 0 || origins.every(o => o.level === 'none')) return 'none'
492  return origins.some(o => o.level === 'none') ? 'mixed' : 'ok'
493}
494
495function trustRefusal(): string | null {
496  if (turnTrust === 'ok') return null
497  return turnTrust === 'mixed' ? MIXED_TURN : NOT_JAY
498}
499
500// ----------------------------------------------------------- approvals ---
501
502function keyOf(id: string, verb: ActionVerb): string {
503  return `${id === '' ? '-' : id}|${verb}`
504}
505
506// Jay's prompt after a confirm question: a yes approves what the question was about, at
507// the prompt's trust level; anything else approves nothing. Every new prompt from Jay ends
508// the approvals his last yes gave.
509async function noteAnswer($: EngineInterface, typed: string, level: 'typed' | 'press'): Promise<void> {
510  for (const [key, a] of approvals) if (a.isFromYes) approvals.delete(key)
511  if (asked === null) return
512  if (isYes(typed, asked.yes, asked.no)) {
513    const until = (await now($)) + APPROVAL_MS
514    for (const key of asked.keys) approvals.set(key, { until, source: level, isFromYes: true })
515  }
516  asked = null
517}
518
519function normal(text: string): string {
520  return text.toLowerCase().replace(/[.!?,;:]+/g, ' ').replace(/\s+/g, ' ').trim()
521}
522
523export function isYes(typed: string, yes: string, no: string): boolean {
524  const said = normal(typed)
525  if (said === normal(yes)) return true
526  if (said === normal(no) || said.includes(normal(no))) return false
527  return YES_WORDS.test(said) && !NO_WORDS.test(said)
528}
529
530// Reads an approval and never uses it up. `typed` asks for one from Jay's own typing.
531async function peekApproval($: EngineInterface, id: string, verb: ActionVerb, need: 'any' | 'typed' = 'any'): Promise<boolean> {
532  const a = approvals.get(keyOf(id, verb))
533  if (a === undefined) return false
534  if ((await now($)) > a.until) {
535    approvals.delete(keyOf(id, verb))
536    return false
537  }
538  return need === 'any' || a.source === 'typed'
539}
540
541// Uses an approval up, right before the effect it guards runs. Returns it, so an effect
542// that never ran can put it back.
543function useApproval(id: string, verb: ActionVerb): Approval | undefined {
544  const key = keyOf(id, verb)
545  const a = approvals.get(key)
546  approvals.delete(key)
547  return a
548}
549
550// ---------------------------------------------------------------- guard ---
551
552function kindOf(s: ChiefSession): ChiefSessionKind {
553  if (s.kind === 'desktop' || s.kind === 'cli' || s.kind === 'bg') return s.kind
554  if (s.id.startsWith('cli_')) return 'cli'
555  if (s.id.startsWith('bg_')) return 'bg'
556  return 'desktop'
557}
558
559function isOwnId(id: string): boolean {
560  return id === 'self' || (localId !== null && id === localId) || id === `cli_${myId}` || id === `bg_${myId}`
561}
562
563// Why the Chief may not act on `id` now, or null with the session when it may. It re-reads
564// the session and its children from the server, so a stale picture never lets an active one
565// go. `desktopRoute`: the desktop archive tool, where a session the server does not know is
566// the app's to answer for.
567async function actionRefusal(
568  $: EngineInterface,
569  verb: ActionVerb,
570  id: string,
571  isConfirmed: boolean,
572  desktopRoute = false,
573): Promise<{ refusal: string | null; session: ChiefSession | null; isBusy: boolean }> {
574  const no = (refusal: string) => ({ refusal, session: null, isBusy: false })
575  if (id === '') return no('Give the session id from cos_sessions.')
576  if (isOwnId(id)) return no('The Chief does not act on its own session.')
577  if (!SESSION_ID.test(id)) return no('Use a session id from cos_sessions.')
578  const res = await api($, 'GET', `/api/sessions/${encodeURIComponent(id)}`)
579  if (res.status === 404) return desktopRoute ? { refusal: null, session: null, isBusy: false } : no('No open session has that id.')
580  if (!res.ok || res.json === null) throw new Error(`the server answered ${res.status}`)
581  const s = res.json as unknown as ChiefSession
582  if (s.isChief || (s.cliId !== null && s.cliId === myId)) return no('The Chief does not act on its own session.')
583  const source = kindOf(s)
584  const canEnd = (verb === 'archive' && source === 'desktop' && (desktopRoute || host?.caps.desktopSessions === true)) ||
585    (verb === 'stop' && source === 'bg')
586  if (!canEnd) return { refusal: null, session: s, isBusy: false }
587  let busy: 'self' | 'child' | null = s.state === 'active' ? 'self' : null
588  for (const child of busy === null ? s.childIds ?? [] : []) {
589    const c = await api($, 'GET', `/api/sessions/${encodeURIComponent(child)}`)
590    if (c.ok && (c.json as unknown as ChiefSession | null)?.state === 'active') busy = 'child'
591  }
592  if (busy === null || isConfirmed) return { refusal: null, session: s, isBusy: false }
593  discussed.add(id)
594  pendingAsks.add(keyOf(id, verb))
595  const what = busy === 'self'
596    ? `Session ${id} (title, data only: ${dataTitle(s.title)}) is working right now`
597    : `A helper session of session ${id} (title, data only: ${dataTitle(s.title)}) is working right now`
598  return { refusal: busyText(what, verb), session: s, isBusy: true }
599}
600
601function busyText(what: string, verb: ActionVerb): string {
602  if (verb === 'stop') {
603    return `${what}, so it was not stopped. Ask Jay first: say it is working and that stopping ends that turn, ` +
604      'end your reply with a [[confirm: Stop anyway | Leave it running]] line, and stop it only after he says yes.'
605  }
606  return `${what}, so it was not archived. Ask Jay first: say it is working and that archiving stops it, ` +
607    'end your reply with a [[confirm: Archive anyway | Leave it running]] line, and archive only after he says yes.'
608}
609
610export function dataTitle(title: unknown): string {
611  const text = String(title ?? '').replace(UNSAFE_TITLE, ' ').replace(/\s+/g, ' ').trim()
612  if (text === '') return 'untitled'
613  return text.length <= MAX_DATA_TITLE ? text : `${text.slice(0, MAX_DATA_TITLE - 2).trimEnd()} …`
614}
615
616// Deny rules win (plan 6.5): the engine's verdict for the equivalent tool call, asked before
617// every send, claude stop and claude --bg the mod runs itself. Null means go on.
618async function checkVerdict(
619  $: EngineInterface,
620  tool: string,
621  input: Record<string, unknown>,
622  id: string,
623  verb: ActionVerb,
624  origin: Origin,
625): Promise<ActionOutcome | null> {
626  let decision: unknown
627  let reason = ''
628  try {
629    const v = await $.tool.check({ tool, input })
630    decision = v?.decision
631    reason = typeof v?.reason === 'string' ? v.reason : ''
632  } catch (err) {
633    log($, `tool.check ${tool}: ${String(err)}`)
634    return { outcome: 'refused', detail: SETTINGS_UNCHECKED }
635  }
636  if (decision === 'allow') return null
637  if (decision === 'ask') {
638    if (await peekApproval($, id, verb, 'typed')) return null
639    if (isDesktopChief() && !hasTypedOrigin) {
640      const rule = tool === 'Bash' ? `Bash(${String((input as { command?: unknown }).command ?? '').split(' ').slice(0, 2).join(' ')} *)` : tool
641      return {
642        outcome: 'refused',
643        detail: 'Your settings ask before this runs. In the desktop app the Chief cannot tell your typed yes from ' +
644          `other messages. Add "${rule}" to permissions.allow in .claude/settings.local.json (or run ` +
645          '/chief-setup permissions), or to permissions.deny to keep it off.',
646        needsJay: 'Run /chief-setup permissions.',
647      }
648    }
649    pendingAsks.add(keyOf(id, verb))
650    if (origin === 'console') return { outcome: 'needsConfirm', detail: "Your settings ask first. Answer in the Chief's session." }
651    const label = { reply: 'Send', archive: 'Archive', stop: 'Stop', open: 'Open', start: 'Start' }[verb]
652    return {
653      outcome: 'needsConfirm',
654      detail: `Your settings ask before this runs. Ask Jay first, and end your reply with a [[confirm: ${label} it | Leave it]] line.`,
655      needsJay: 'Say yes to let it run.',
656    }
657  }
658  if (decision === 'deny') {
659    log($, `tool.check ${tool} denied: ${cleanLine(reason, 160)}`)
660    return { outcome: 'refused', detail: SETTINGS_BLOCK }
661  }
662  return { outcome: 'refused', detail: SETTINGS_UNCHECKED }
663}
664
665function isDesktopChief(): boolean {
666  return host?.surface === 'desktop' || startSurface === 'desktop'
667}
668
669// ------------------------------------------------------------ capabilities ---
670
671// What this session can do, not which app hosts it (plan D1). It never throws.
672async function detectCaps($: EngineInterface): Promise<ChiefHost> {
673  let names: string[] = []
674  let surfaces: readonly string[] = []
675  try {
676    names = (await $.tool.list()).map(t => t.name)
677  } catch (err) {
678    log($, `tool.list: ${String(err)}`)
679  }
680  try {
681    surfaces = await $.session.surfaces()
682  } catch {
683    surfaces = []
684  }
685  const isListed = names.includes(ARCHIVE_TOOL)
686  const isDesktopSurface = surfaces.includes('desktop') || startSurface === 'desktop'
687  const first = surfaces[0]
688  const surface = first === 'terminal' || first === 'desktop' || first === 'vscode' || first === 'mobile' ? first : startSurface
689  const caps: ChiefCaps = {
690    desktopSessions: isListed || isDesktopSurface,
691    desktopListed: isListed,
692    desktopStart: names.includes(START_TOOL) || isDesktopSurface,
693    // Constants in v1: contract.md names the fields; a refusal shows per send or per run.
694    sendMessage: true,
695    listAgents: names.includes('ListAgents'),
696    cliStop: true,
697    cliStart: !(isListed || isDesktopSurface),
698  }
699  return { surface, caps }
700}
701
702// Re-reads the caps before an action, and tells the server when the desktop tools came or went.
703async function refreshCaps($: EngineInterface): Promise<ChiefHost> {
704  const before = host
705  host = await detectCaps($)
706  if (before !== null && before.caps.desktopSessions !== host.caps.desktopSessions) {
707    void post($, '/api/mod/caps', { cliId: myId, host })
708  }
709  return host
710}
711
712// ---------------------------------------------------------------- router ---
713
714// Every session action goes through here, from the console and from cos_act. The server's
715// actions map decides yes, app or no (plan-b.md 4.1); the mod's own checks only make it stricter.
716async function runAction($: EngineInterface, req: ActionRequest): Promise<ActionOutcome> {
717  const h = await refreshCaps($)
718  if (req.verb === 'start') return startSession($, req)
719  const verb = req.verb as SessionAction
720  const id = (req.id ?? '').trim()
721  const isConfirmed = (req.origin === 'console' && req.confirmed === true) || (await peekApproval($, id, verb))
722  let guard: Awaited<ReturnType<typeof actionRefusal>>
723  try {
724    guard = await actionRefusal($, verb, id, isConfirmed)
725  } catch {
726    return { outcome: 'refused', detail: NOT_CHECKED }
727  }
728  if (guard.refusal !== null) {
729    if (!guard.isBusy) return { outcome: 'refused', detail: guard.refusal }
730    return {
731      outcome: 'needsConfirm',
732      detail: req.origin === 'console' ? `${guard.refusal.split('. Ask Jay')[0]}.` : guard.refusal,
733      needsJay: verb === 'stop' ? 'Say yes to stop it while it works.' : 'Say yes to archive it while it works.',
734    }
735  }
736  const s = guard.session!
737  // A console confirm approves this action once, for this route and for a hand-off's own call.
738  if (req.origin === 'console' && req.confirmed === true && verb !== 'reply') {
739    approvals.set(keyOf(id, verb), { until: (await now($)) + APPROVAL_MS, source: 'press', isFromYes: false })
740  }
741  if (verb === 'reply' && (req.text ?? '').trim() === '') return { outcome: 'refused', detail: 'Say what the reply should say.' }
742  const source = kindOf(s)
743  const avail = s.actions?.[verb]
744  if (avail === 'no') return { outcome: 'refused', detail: s.actionNotes?.[verb] ?? 'The server gave no reason.' }
745  if (avail === 'app') {
746    // For console presses the server opens the app itself; an item that meets app is stale.
747    if (req.origin === 'console') {
748      return { outcome: 'unsupported', detail: 'The session changed since the console asked, so nothing was done. Try again.' }
749    }
750    return openInApp($, s, verb)
751  }
752  if (avail === undefined) {
753    // An old server with no actions map: only the desktop app's own archive and reply run.
754    const isOldPath = source === 'desktop' && (verb === 'archive' || verb === 'reply') && h.caps.desktopSessions
755    if (!isOldPath) return { outcome: 'refused', detail: OUT_OF_DATE }
756  }
757  const changed = (): ActionOutcome => {
758    void post($, '/api/mod/caps', { cliId: myId, host: h })
759    return { outcome: 'unsupported', detail: "The Chief's tools changed since the server last heard. Try again." }
760  }
761  switch (`${source}:${verb}`) {
762    case 'desktop:archive': return h.caps.desktopSessions ? archiveDesktop($, s, req) : changed()
763    case 'desktop:reply': return h.caps.desktopSessions ? replyDesktop($, s, req) : sendTo($, s, req.text!, req.origin)
764    case 'desktop:open': return openInApp($, s, 'open')
765    case 'cli:reply': case 'bg:reply': return sendTo($, s, req.text!, req.origin)
766    case 'bg:stop': return stopBackground($, s, isConfirmed, req.origin)
767    default: return { outcome: 'unsupported', detail: 'The Chief cannot take that route now.' }
768  }
769}
770
771// Row 1: the desktop app's own archive.
772async function archiveDesktop($: EngineInterface, s: ChiefSession, req: ActionRequest): Promise<ActionOutcome> {
773  const approval = useApproval(s.id, 'archive')
774  try {
775    const result = await $.mcp.call(SESSIONS_SERVER, 'archive_session', { session_id: s.id, reason: req.reason ?? '' })
776    const text = textOf(result.content)
777    return result.isError ? { outcome: 'failed', detail: text || 'The app did not archive it.' } : { outcome: 'done', detail: text || 'Archived.' }
778  } catch (err) {
779    log($, `archive_session was refused (${String(err)})`)
780    // Nothing ran, so the approval stays for the Chief's own call.
781    if (approval !== undefined) approvals.set(keyOf(s.id, 'archive'), approval)
782    if (req.origin === 'model') {
783      return { outcome: 'failed', detail: 'The app refused the archive. Try mcp__ccd_session_mgmt__archive_session.' }
784    }
785    handOff($, `[Action from the console] Jay pressed Archive for ${sessionRef(s.id, s.title)}. ` +
786      'Look it up with cos_sessions, apply your archive rules, then archive it with the archive_session ' +
787      'tool and tell Jay in one short sentence whether it worked.')
788    return { outcome: 'handedOff', detail: 'Asked the Chief to archive it with its own tools. Its reply says whether it worked.' }
789  }
790}
791
792// Row 4: a desktop Chief's reply: the app's send, then a send by CLI id, then a hand-off.
793async function replyDesktop($: EngineInterface, s: ChiefSession, req: ActionRequest): Promise<ActionOutcome> {
794  const text = req.text!
795  try {
796    const result = await $.mcp.call(SESSIONS_SERVER, 'send_message', { session_id: s.id, message: text })
797    const said = textOf(result.content)
798    return result.isError ? { outcome: 'failed', detail: said || 'The app did not send it.' } : { outcome: 'done', detail: said || 'Sent.' }
799  } catch (err) {
800    log($, `send_message was refused (${String(err)})`)
801  }
802  if (s.cliId !== null && s.cliId !== '') {
803    const verdict = await checkVerdict($, 'SendMessage', { to: s.cliId, message: text }, s.id, 'reply', req.origin)
804    if (verdict !== null) return verdict
805    useApproval(s.id, 'reply')
806    // Never by name: a string address is a name as ListAgents lists it (plan 5.2).
807    const sent = await $.session.send({ to: { sessionId: s.cliId }, text }).catch(() => null)
808    if (sent?.isDelivered === true) return { outcome: 'done', detail: 'Sent.' }
809    if (sent !== null && sent.isDelivered === false) log($, `session.send: ${cleanLine(sent.reason, 160)}`)
810  }
811  if (req.origin === 'model') return { outcome: 'failed', detail: 'The app refused the send. Try again in a moment.' }
812  handOff($, `[Action from the console] Jay typed a reply for ${sessionRef(s.id, s.title)} in the ` +
813    'console reply box. Send the text between the two marker lines to that session with the send_message ' +
814    'tool, word for word, then tell Jay in one short sentence that it went.\n' +
815    `<<<reply\n${text}\n>>>`)
816  return { outcome: 'handedOff', detail: 'Asked the Chief to send it with its own tools.' }
817}
818
819// The Chief does it with its own tools, in a turn of its own once the session is idle.
820function handOff($: EngineInterface, text: string): void {
821  originsSinceTurn.push({ kind: 'console-action', level: 'press' })
822  $.prompt.submit({ text }).catch(err => log($, `hand-off prompt: ${String(err)}`))
823}
824
825const SEND_FAILED: Record<ChiefSessionKind, string> = {
826  desktop: 'That desktop session could not be reached from this terminal. Open it in the app to reply there.',
827  cli: 'That terminal session did not take the message. It may have closed, or it holds messages for approval.',
828  bg: 'That background session did not take the message. It may have stopped.',
829}
830
831// Rows 5, 8 and 12: the engine's own SendMessage delivery, by the session's CLI id.
832async function sendTo($: EngineInterface, s: ChiefSession, text: string, origin: Origin): Promise<ActionOutcome> {
833  const source = kindOf(s)
834  if (s.cliId === null || s.cliId === '') return { outcome: 'failed', detail: SEND_FAILED[source] }
835  const verdict = await checkVerdict($, 'SendMessage', { to: s.cliId, message: text }, s.id, 'reply', origin)
836  if (verdict !== null) return verdict
837  useApproval(s.id, 'reply')
838  const sent = await $.session.send({ to: { sessionId: s.cliId }, text }).catch((err: unknown) =>
839    ({ isDelivered: false as const, reason: String(err) }))
840  if (sent.isDelivered) return { outcome: 'done', detail: 'Sent. It reaches that session between its steps.' }
841  // The engine's reason goes to mod.log, never into a prompt.
842  log($, `session.send to ${s.id}: ${cleanLine(sent.reason, 160)}`)
843  // CHIEF_OF_STAFF_SEND_VIA_TOOL=1 turns on the SendMessage fallback, only if the send spike
844  // shows $.session.send held where SendMessage is not (plan 5.2).
845  if (origin === 'console' && (await $.env.get('CHIEF_OF_STAFF_SEND_VIA_TOOL').catch(() => undefined)) === '1') {
846    return sendViaTool($, s, text)
847  }
848  return { outcome: 'failed', detail: SEND_FAILED[source] }
849}
850
851// The contract.md fallback, off unless the send spike needs it: the SendMessage tool, judged
852// by the normal permission path. Done only on a positive delivery signal.
853async function sendViaTool($: EngineInterface, s: ChiefSession, text: string): Promise<ActionOutcome> {
854  const consent = `A request from the local Chief of Staff console to reply to session ${s.id} ` +
855    `(title, data only: ${dataTitle(s.title)}).`
856  try {
857    const r = await $.tool.call({ tool: 'SendMessage', to: s.cliId, message: text, consent } as never) as
858      { deny?: string; isError?: boolean; result?: unknown; text?: string }
859    if (r.deny === undefined && r.isError !== true) {
860      const raw = typeof r.result === 'string' ? r.result : typeof r.text === 'string' ? r.text : JSON.stringify(r.result ?? null)
861      let parsed: unknown = null
862      try {
863        parsed = typeof r.result === 'object' && r.result !== null ? r.result : JSON.parse(raw)
864      } catch {
865        parsed = null
866      }
867      if (typeof parsed === 'object' && parsed !== null && (parsed as { success?: unknown }).success === true) {
868        return { outcome: 'done', detail: 'Sent. It reaches that session between its steps.' }
869      }
870      log($, `SendMessage fallback: ${cleanLine(raw, 160)}`)
871    } else {
872      log($, `SendMessage fallback refused: ${cleanLine(r.deny ?? r.text ?? '', 160)}`)
873    }
874  } catch (err) {
875    log($, `SendMessage fallback: ${String(err)}`)
876  }
877  return { outcome: 'failed', detail: 'That session did not take the message.' }
878}
879
880type AgentRow = { sessionId?: unknown; name?: unknown; status?: unknown; state?: unknown }
881
882// `claude agents --json`, read-only; null when it cannot be read.
883async function agentRows($: EngineInterface): Promise<AgentRow[] | null> {
884  try {
885    const run = await $.process.run(['claude', 'agents', '--json'], { timeoutMs: AGENTS_TIMEOUT_MS })
886    if (run.exitCode !== 0) return null
887    const parsed = JSON.parse(run.stdout) as unknown
888    const rows = Array.isArray(parsed) ? parsed
889      : Array.isArray((parsed as { agents?: unknown })?.agents) ? (parsed as { agents: unknown[] }).agents
890        : Array.isArray((parsed as { sessions?: unknown })?.sessions) ? (parsed as { sessions: unknown[] }).sessions
891          : null
892    return rows === null ? null : rows.filter((r): r is AgentRow => typeof r === 'object' && r !== null)
893  } catch (err) {
894    log($, `claude agents: ${String(err)}`)
895    return null
896  }
897}
898
899// Row 10: `claude stop <bgId>`. Stop keeps the conversation, so it can be resumed.
900async function stopBackground($: EngineInterface, s: ChiefSession, isConfirmed: boolean, origin: Origin): Promise<ActionOutcome> {
901  const bgId = typeof s.bgId === 'string' ? s.bgId : ''
902  if (bgId === '') {
903    return { outcome: 'failed', detail: "The server did not give this background session's id, so it was not stopped." }
904  }
905  if (!ARG_ID.test(bgId)) {
906    return { outcome: 'failed', detail: "This background session's id cannot be passed to claude safely, so it was not stopped." }
907  }
908  const verdict = await checkVerdict($, 'Bash', { command: `claude stop ${bgId}` }, s.id, 'stop', origin)
909  if (verdict !== null) return verdict
910  // A fresh look right before the stop: it may have woken up since the server's snapshot.
911  const rows = await agentRows($)
912  if (rows === null) return { outcome: 'refused', detail: 'The Chief could not check whether it is working, so it was not stopped.' }
913  const row = rows.find(r => r.sessionId === s.cliId)
914  if (row === undefined) return { outcome: 'refused', detail: 'It already finished. Its conversation is kept.' }
915  if ((row.status === 'busy' || row.state === 'working') && !isConfirmed) {
916    discussed.add(s.id)
917    pendingAsks.add(keyOf(s.id, 'stop'))
918    const text = busyText(`Session ${s.id} (title, data only: ${dataTitle(s.title)}) is working right now`, 'stop')
919    return {
920      outcome: 'needsConfirm',
921      detail: origin === 'console' ? `${text.split('. Ask Jay')[0]}.` : text,
922      needsJay: 'Say yes to stop it while it works.',
923    }
924  }
925  const used = useApproval(s.id, 'stop')
926  // A stop that did not happen puts Jay's yes back, so a retry needs no new yes.
927  const keepYes = () => {
928    if (used !== undefined) approvals.set(keyOf(s.id, 'stop'), used)
929  }
930  try {
931    const run = await $.process.run(['claude', 'stop', bgId], { timeoutMs: STOP_TIMEOUT_MS })
932    if (run.exitCode === 0) return { outcome: 'done', detail: 'Stopped. Its conversation is kept, so it can be resumed.' }
933    const line = (run.stderr.trim() || run.stdout.trim()).split(/\r?\n/)[0] ?? ''
934    // claude may fail to confirm a stop that worked, or a job whose process is gone: look again.
935    const after = await agentRows($)
936    if (after !== null && !after.some(r => r.sessionId === s.cliId)) {
937      return { outcome: 'done', detail: 'It is no longer running. Its conversation is kept, so it can be resumed.' }
938    }
939    keepYes()
940    return {
941      outcome: 'failed',
942      detail: `claude stop did not stop it: ${cleanLine(line, 160)}. It is still listed. Try again in a moment; your yes still counts for 2 minutes. ` +
943        `If it keeps failing, its process may be gone while its record stays: run claude stop ${bgId} in a terminal.`,
944    }
945  } catch (err) {
946    keepYes()
947    log($, `claude stop: ${String(err)}`)
948    return { outcome: 'failed', detail: 'The claude command was not found, so it was not stopped.' }
949  }
950}
951
952// Rows 2 and 6, and every `app` cell: the server opens the session in the Claude app.
953async function openInApp($: EngineInterface, s: ChiefSession, verb: ActionVerb): Promise<ActionOutcome> {
954  const res = await post($, `/api/sessions/${encodeURIComponent(s.id)}/open`, {})
955  const isOpen = res !== undefined && res.ok
956  if (verb === 'archive') {
957    return isOpen
958      ? { outcome: 'opened', detail: 'Opened it in the Claude app. Archive it there. Nothing was archived from here.' }
959      : { outcome: 'failed', detail: 'The Claude app did not open, so nothing was archived. Archive it in the app.' }
960  }
961  if (verb === 'reply') {
962    return isOpen
963      ? { outcome: 'opened', detail: 'Opened it in the Claude app. Reply there. Nothing was sent from here.' }
964      : { outcome: 'failed', detail: 'The Claude app did not open, so nothing was sent. Reply in the app.' }
965  }
966  return isOpen
967    ? { outcome: 'opened', detail: 'Opened it in the Claude app.' }
968    : { outcome: 'failed', detail: 'The Claude app did not open it.' }
969}
970
971// Rows 14 and 15: a terminal Chief starts background sessions with `claude --bg`.
972async function startSession($: EngineInterface, req: ActionRequest): Promise<ActionOutcome> {
973  if (host?.caps.desktopSessions === true) {
974    return { outcome: 'refused', detail: 'In the desktop app, start sessions with mcp__ccd_session__start_session.' }
975  }
976  const cwd = (req.cwd ?? '').trim()
977  const isDir = cwd !== '' && (await $.fs.stat(cwd).then(st => st.kind === 'dir', () => false))
978  if (!isDir) return { outcome: 'refused', detail: 'That folder does not exist.' }
979  const text = (req.text ?? '').trim()
980  if (text === '') return { outcome: 'refused', detail: 'Say what the new session should do.' }
981  if (text.length > MAX_START_TEXT) return { outcome: 'refused', detail: 'The task text is too long. Keep it under 4000 characters.' }
982  if (text.startsWith('-')) return { outcome: 'refused', detail: 'The task text cannot start with a dash.' }
983  const { name, tag } = startName(req.title ?? '', cwd, text)
984  if (!ARG_ID.test(name)) return { outcome: 'refused', detail: 'That title cannot be used as a session name.' }
985  const model = typeof req.model === 'string' && START_MODELS.has(req.model) ? req.model : undefined
986  // The same request inside two minutes is the same start: the tag is reserved before any wait.
987  const at = await now($)
988  const recent = recentStarts.get(tag)
989  if (recent !== undefined && at - recent < RECENT_START_MS) return { outcome: 'done', detail: `That session is already starting: ${name}.` }
990  recentStarts.set(tag, at)
991  const free = (o: ActionOutcome): ActionOutcome => (recentStarts.delete(tag), o)
992  const rows = await agentRows($)
993  if (rows === null) log($, 'start: the duplicate check could not read claude agents')
994  const running = rows?.find(r => typeof r.name === 'string' && r.name.endsWith(`-${tag}`))
995  if (running !== undefined) return { outcome: 'done', detail: `That session is already running: ${String(running.name)}.` }
996  const argv = ['claude', '--bg', '--name', name, ...(model !== undefined ? ['--model', model] : []), '--', text]
997  const verdict = await checkVerdict($, 'Bash', { command: shellLine(argv) }, '', 'start', 'model')
998  if (verdict !== null) return free(verdict)
999  useApproval('', 'start')
1000  let result: ActionOutcome
1001  try {
1002    const run = await $.process.run(argv, { cwd, timeoutMs: BG_START_TIMEOUT_MS })
1003    if (run.exitCode !== 0) {
1004      const line = (run.stderr.trim() || run.stdout.trim()).split(/\r?\n/)[0] ?? ''
1005      return free({ outcome: 'failed', detail: `claude --bg did not start it: ${cleanLine(line, 160)}` })
1006    }
1007    recentStarts.set(tag, await now($))
1008    result = { outcome: 'done', detail: `Starting ${name}. It shows in the console within a minute.` }
1009  } catch (err) {
1010    // A timeout, or a worker that kept the pipe open: look once, and keep the tag reserved.
1011    log($, `claude --bg: ${String(err)}`)
1012    const after = await agentRows($)
1013    const seen = after?.some(r => typeof r.name === 'string' && r.name.endsWith(`-${tag}`)) === true
1014    if (!seen) {
1015      return {
1016        outcome: 'failed',
1017        detail: 'claude --bg did not answer in time. It may still be starting, so check cos_sessions before you ask again.',
1018      }
1019    }
1020    result = { outcome: 'done', detail: `Starting ${name}. It shows in the console within a minute.` }
1021  }
1022  if (jevPending !== null) {
1023    const id = jevPending
1024    jevPending = null
1025    await post($, '/api/mod/jev-outcome', { id, route: 'new_session', model: model ?? null })
1026  }
1027  return result
1028}
1029
1030// The one name format: a slug of the title, then a tag that the same folder and task always give.
1031function startName(title: string, cwd: string, text: string): { name: string; tag: string } {
1032  const tag = `chief-${shortHash(`${cwd}\n${text}`)}`
1033  const slug = (title.trim() || 'task').replace(/\s+/g, '-').replace(/[^A-Za-z0-9_-]/g, '').replace(/^[^A-Za-z0-9]+/, '')
1034    .slice(0, 50)
1035  return { name: `${slug === '' ? 'task' : slug}-${tag}`, tag }
1036}
1037
1038function shortHash(text: string): string {
1039  let h = 0x811c9dc5
1040  for (let i = 0; i < text.length; i += 1) {
1041    h ^= text.charCodeAt(i)
1042    h = Math.imul(h, 0x01000193) >>> 0
1043  }
1044  return h.toString(16).padStart(8, '0').slice(0, 6)
1045}
1046
1047// The command as the permission check reads it. The run itself is argv, with no shell.
1048function shellLine(argv: readonly string[]): string {
1049  return argv.map(a => (/^[A-Za-z0-9_./:=-]+$/.test(a) ? a : `"${a.replace(/"/g, '\\"')}"`)).join(' ')
1050}
1051
1052// ---------------------------------------------------------------- cos_act ---
1053
1054async function actForModel($: EngineInterface, input: Record<string, unknown>): Promise<Record<string, unknown>> {
1055  const action = typeof input.action === 'string' ? input.action : ''
1056  const id = typeof input.id === 'string' ? input.id.trim() : ''
1057  const reply = (o: ActionOutcome) => ({ ok: o.outcome === 'done', ...o })
1058  if (!isChief) return reply({ outcome: 'refused', detail: 'This session is not the Chief of Staff.' })
1059  if (!['archive', 'stop', 'reply', 'open', 'start'].includes(action)) {
1060    return reply({ outcome: 'refused', detail: 'Use one of archive, stop, reply, open or start.' })
1061  }
1062  const verb = action as ActionVerb
1063  const distrust = trustRefusal()
1064  if (distrust !== null) {
1065    logAct($, verb, id, turnTrust === 'mixed' ? 'mixed' : 'peer', 'refused', distrust)
1066    return reply({ outcome: 'refused', detail: distrust })
1067  }
1068  if (verb !== 'start' && id === '') return reply({ outcome: 'refused', detail: 'Give the session id from cos_sessions.' })
1069  if (id !== '') discussed.add(id)
1070  const str = (v: unknown) => (typeof v === 'string' ? v : undefined)
1071  // `confirmed` is not read: only Jay confirms.
1072  const o = await runAction($, {
1073    verb, id, text: str(input.text), reason: str(input.reason), cwd: str(input.cwd), title: str(input.title),
1074    model: str(input.model), origin: 'model',
1075  })
1076  if (o.needsJay === undefined) {
1077    if (o.outcome === 'opened' && verb === 'archive') o.needsJay = 'Archive it in the Claude app.'
1078    if (o.outcome === 'opened' && verb === 'reply') o.needsJay = 'Reply to it in the Claude app.'
1079    if (o.outcome === 'refused' && (verb === 'archive' || verb === 'stop') && id.startsWith('cli_')) {
1080      o.needsJay = 'Close that terminal session yourself: type /exit in it.'
1081    }
1082  }
1083  logAct($, verb, id, 'model', o.outcome, o.detail)
1084  if (verb !== 'start') {
1085    await settleHandoff($, verb, id, o.outcome === 'done' ? { result: o.detail } : { deny: o.detail })
1086  }
1087  return reply(o)
1088}
1089
1090async function registerActTool($: EngineInterface, isActive: boolean): Promise<void> {
1091  await $.tool.register({
1092    name: ACT_TOOL,
1093    description:
1094      "Chief of Staff: act on one of Jay's sessions, with the Chief's guard. archive (desktop sessions), stop " +
1095      '(background sessions, ends the running turn, keeps the conversation), reply (send text to a session), open ' +
1096      '(open a desktop session in the Claude app) and start (a new background session in a folder, from a terminal ' +
1097      'Chief). Ids come from cos_sessions. The result says done, refused, failed, needsConfirm or opened; ' +
1098      'needsConfirm means ask Jay first, and needsJay says what he must do.',
1099    inputSchema: {
1100      type: 'object',
1101      properties: {
1102        action: { type: 'string', enum: ['archive', 'stop', 'reply', 'open', 'start'], description: 'What to do.' },
1103        id: { type: 'string', description: 'The session id (local_, cli_ or bg_) from cos_sessions. Required except for start.' },
1104        text: { type: 'string', description: "The reply's exact words, or the new session's task for start." },
1105        reason: { type: 'string', description: 'Why, in a few words (archive).' },
1106        cwd: { type: 'string', description: 'The absolute folder the new session runs in (start).' },
1107        title: { type: 'string', description: "The new session's short name (start)." },
1108        model: { type: 'string', enum: ['haiku', 'sonnet', 'opus'], description: 'The model for the new session (start).' },
1109      },
1110      required: ['action'],
1111    },
1112    isDeferred: !isActive,
1113  })
1114}
1115
1116// ------------------------------------------------------------- the role ---
1117
1118// Marks this session as the Chief now, then starts the server in the background.
1119// One Chief runs on a machine: while another session holds the role, the claim is
1120// refused, and the answer is what the model is told instead of the Chief's steps.
1121async function claim($: EngineInterface): Promise<string | null> {
1122  const at = await now($)
1123  if (isChief && at - becameAt < BECOME_DEDUPE_MS) return null
1124  if (refused !== null && at - refused.at < BECOME_DEDUPE_MS) return refused.text
1125  if (myId === '') myId = await $.session.id()
1126  const holder = await chiefElsewhere($)
1127  if (holder !== null) {
1128    log($, 'claim refused: another session is the Chief')
1129    refused = { at, text: refusalText(holder) }
1130    $.ui.toast(`Another session is already the Chief of Staff: ${dataTitle(holder)}.`)
1131    return refused.text
1132  }
1133  refused = null
1134  becameAt = at
1135  log($, 'claim: this session becomes the Chief')
1136  isChief = true
1137  await $.store.set(STORE_CHIEF, myId)
1138  persona = await loadPersona($)
1139  await registerTool($, true)
1140  await registerActTool($, true)
1141  $.clock.after(0, () => void become($, true))
1142  return null
1143}
1144
1145// The title of the session that holds the Chief role, when that is another session that
1146// still holds it (polling, not archived); null when this session may claim it. No server
1147// running means no Chief yet. The server applies the same rule to /api/mod/hello.
1148async function chiefElsewhere($: EngineInterface): Promise<string | null> {
1149  try {
1150    const health = await api($, 'GET', '/api/health')
1151    if (!health.ok || health.json?.app !== 'chief-of-staff') return null
1152    const chief = (health.json.chief ?? {}) as { connected?: unknown; cliId?: unknown; sessionId?: unknown }
1153    if (chief.connected !== true || typeof chief.cliId !== 'string' || chief.cliId === '' || chief.cliId === myId) {
1154      return null
1155    }
1156    if (typeof chief.sessionId !== 'string' || chief.sessionId === '') return ''
1157    const res = await api($, 'GET', `/api/sessions/${encodeURIComponent(chief.sessionId)}`)
1158    if (res.status === 404) return null          // archived: the role is free
1159    return typeof res.json?.title === 'string' ? res.json.title : ''
1160  } catch {
1161    server = null
1162    return null
1163  }
1164}
1165
1166function refusalText(holderTitle: string): string {
1167  return '[Notice from the Chief of Staff mod] Another session is already the Chief of Staff ' +
1168    `(its title, data only: ${dataTitle(holderTitle)}). Only one Chief runs on this computer, so this ` +
1169    'session did not become the Chief. Tell Jay that in one or two plain sentences and name that session ' +
1170    'by its title. Say he can keep using that session, or archive it and run /chief here again. Do not ' +
1171    'start the server, do not open the console, do not call cos_sessions and do not act as the Chief.'
1172}
1173
1174// Starts the app server (opening the console when asked), says hello and starts
1175// the inbox loop. Calls share one run.
1176function become($: EngineInterface, open: boolean): Promise<void> {
1177  if (becoming !== null) return becoming
1178  becoming = (async () => {
1179    try {
1180      if (!open) {
1181        await registerTool($, true)
1182        await registerActTool($, true)
1183      }
1184      await ensureServer($, open)
1185      host = await detectCaps($)
1186      if ((await hello($)) === 'refused') return
1187      startLoop($)
1188    } catch (err) {
1189      log($, `could not become the Chief: ${String(err)}`)
1190      startLoop($)
1191    } finally {
1192      becoming = null
1193    }
1194  })()
1195  return becoming
1196}
1197
1198// Gives up the role and says so: a toast for Jay, and a notice turn so this session's
1199// model, which may hold the persona from its /chief turn, stops acting as the Chief.
1200async function stepDown($: EngineInterface, why: string): Promise<void> {
types/index.d.ts 83 lines
1// The Chief of Staff mod's contract: the shapes it trades with the app server
2// (docs/contracts.md sections 4 and 5). The mod keeps no $.state values.
3
4export type ChiefInboxItem =
5  | { id: string; type: 'prompt'; text: string; from?: 'console' | 'voice' | 'notice'; hint?: string; jev?: JevDecision }
6  | { id: string; type: 'archive'; sessionId: string; title: string; kind?: ChiefSessionKind; reason: string; confirmed?: boolean }
7  | { id: string; type: 'stop'; sessionId: string; title: string; kind?: ChiefSessionKind; reason?: string; confirmed?: boolean }
8  | { id: string; type: 'reply'; sessionId: string; title: string; kind?: ChiefSessionKind; text: string }
9
10// Jev's advice for one request (server/cos/jev/decision.py).
11export type JevDecision = {
12  model: 'haiku' | 'sonnet' | 'opus'
13  route: 'chief' | 'new_session'
14  background: boolean
15  estimate_s: number
16  filler: 'lookup' | 'work' | 'chat'
17  source: 'rules' | 'user' | 'local' | 'hosted'
18  ms: number
19  override: boolean
20}
21
22export type ChiefConfirm = { question: string; yes: string; no: string }
23
24export type ChiefSessionState = 'you' | 'active' | 'wait' | 'stale'
25
26// desktop: a Claude app session (local_ id). cli: an interactive terminal session (cli_).
27// bg: a background session (bg_), stopped with `claude stop <bgId>`.
28export type ChiefSessionKind = 'desktop' | 'cli' | 'bg'
29
30// yes: the Chief can do it now. app: only by opening the session in the Claude app.
31// no: not possible; the matching actionNotes entry says why.
32export type Avail = 'yes' | 'app' | 'no'
33
34export type ActionVerb = 'reply' | 'archive' | 'stop' | 'open' | 'start'
35
36export type ChiefSessionActions = { reply: Avail; archive: Avail; stop: Avail; open: Avail }
37
38export type ChiefActionNotes = Partial<Record<keyof ChiefSessionActions, string>>
39
40// expired is the server's alone: a hand-off or an action with no result in time.
41export type ActionOutcomeKind =
42  | 'done' | 'refused' | 'failed' | 'needsConfirm' | 'opened' | 'handedOff' | 'unsupported' | 'expired'
43
44export type ChiefCaps = {
45  desktopSessions: boolean
46  desktopListed: boolean
47  desktopStart: boolean
48  sendMessage: boolean
49  listAgents: boolean
50  cliStop: boolean
51  cliStart: boolean
52}
53
54export type ChiefHost = { surface: 'terminal' | 'desktop' | 'vscode' | 'mobile' | null; caps: ChiefCaps }
55
56export type ChiefSession = {
57  id: string
58  cliId: string | null
59  title: string
60  // Older servers sent interactive or background; the id prefix decides then.
61  kind: ChiefSessionKind | 'interactive' | 'background'
62  bgId?: string
63  actions?: ChiefSessionActions
64  actionNotes?: ChiefActionNotes
65  state: ChiefSessionState
66  stateReason: string
67  live: boolean
68  liveStatus: 'busy' | 'idle' | 'waiting' | null
69  parentId: string | null
70  childIds: string[]
71  repo: string | null
72  branch: string | null
73  prNumber: number | null
74  prState: string | null
75  lastActivityAt: number | null
76  summary: string | null
77  needsAction: string | null
78  isChief: boolean
79  inView?: boolean
80}
81
82export type ChiefServerInfo = { port: number; token: string }
83