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…

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.
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:
server/setup.ps1 in the background and reports each step. This is optional and takes 10 to 30 minutes..claude/settings.local.json only after you say yes./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.
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.
/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.| Session | From a desktop Chief | From a terminal Chief |
|---|---|---|
| Desktop app | message, archive (asks first if working), open, start | message 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 |
| Terminal | message | message |
| Background | message, 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.
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 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:
/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.
Set them with /plugin configure chief-of-staff@personal-plugins.
| Setting | Default | What it does |
|---|---|---|
stale_hours | 24 | A session with no activity this long counts as stale |
recent_days | 3 | The graph and roster show live sessions and sessions active in this many days |
speak_replies | true | Speak the Chief's replies |
listen | wake | wake (always listening for the wake word), ptt (push to talk) or off |
voice | auto | auto 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_name | Chief | What you call your Chief; it answers to this name |
wake_word | hey chief | The wake phrase, usually hey plus the name; enroll it again after a change |
wake_ack | chime | What you hear when the wake word is heard: chime, word (it says a word such as yes) or off |
fillers | true | A quick spoken "on it" as soon as you ask, before the full answer |
notify | true | Proactive notices |
quiet_hours | 22:00-07:00 | No proactive notices in this range; empty for none |
jev_provider | auto | Jev's provider: auto (what /chief-setup picked), rules, local or hosted |
jev_adapter, jev_model, jev_endpoint | from setup | Override the model Jev asks: anthropic or openai_compat, a model name, and a base URL |
jev_timeout_ms | 800 | How long Jev waits for a model before it uses its rules |
jev_key_ref | empty | Where the hosted key lives: wincred:<name> or env:<NAME> |
jev_api_key | empty | A hosted key, kept in Claude's secure settings and only in the server's memory |
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.
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).
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.
hooks/register.tsx 1708 lines1import 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 lines1// 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