Gathers what only the owner can act on across sessions: open questions, refusals that hand the act to the person, and owner_action signals, each with its…

Keep One Repo, Unblock Sessions. A working method for running several AI coding sessions against one codebase without them colliding, losing work, or quietly agreeing with each other.
KORUS grew out of tooling for a single project and has been revised repeatedly under real use. Its properties were discovered, not designed -- most of what is known about it came from running it and measuring what broke.
This repository exists to change that. It uses Spec Kit for spec-driven development: the constitution first, then the spec, then the plan.
Nothing here is stable yet. Findings are still being consolidated from three days of measured operation, and some published guidance has already been shown wrong.
The KORUS Constitution is the document to read first. It holds the rules a session, a seat, a gate or a later spec may not break, and every article names the evidence behind it so a reader can check rather than trust.
Alongside it are the seat playbooks in roles/, the working guides in docs/, and the specs in specs/. The constitution outranks all of them: where a playbook and an article disagree, the article wins and the playbook is the bug.
Thirteen articles, in short:
| I | No session is the only reader of its own work |
| II | Publish readings, not conclusions |
| III | A gate that cannot check identity must make refusal legible |
| IV | Every claim names the condition it did not vary |
| V | No rule may manufacture its own evidence |
| VI | A number without its instrument is not a measurement |
| VII | Waiting is a design cost and it is measured |
| VIII | The account roster is assigned by the Owner, and no design may infer it |
| IX | Built for Claude Code, and no design may require a particular surface |
| X | A seat that cannot be measured cannot be steered |
| XI | A seat's job is to write something down |
| XII | The shared write surface is the boundary that binds, not the account |
| XIII | Work reaches the model through Claude Code, never through the API |
It is at v1.16.0 and it expects to be wrong in places. Most articles rest on a small number of observations, several from a single night of operation, and the document says so. Amendments require evidence, and retired text stays with the reason it was retired.
Run more than one AI session on one repository and four things go wrong:
KORUS is the set of roles, gates and instruments that address these.
Both on the repository this tooling was developed in. Over 30 days, 166 sessions ran with their working directory in the shared primary checkout. Both percentages below are shares of the Edit/Write calls those sessions made, not of every write on the machine:
This page is the record for both figures. Neither has been re-measured, and nothing in this repository can recompute them.
Work is divided among seats, each a session with one job:
| Seat | What it does | Lifetime |
|---|---|---|
| Manager | Plans, runs workers, holds the owner's attention | long-lived |
| Builder | Takes one brief, does the work, opens a pull request | one turn |
| Steward | Writes files other seats read | cron, no model calls |
| Lander | Decides merge order | long-lived |
Nine further seats were tried and retired: the Console on 2026-09-10, and another on 2026-09-12 that is deliberately left unnamed here, with nothing replacing it. Why each went is part of the record this repository holds.
.specify/memory/constitution.md the rules nothing may break
.specify/ Spec Kit scaffold: templates and scripts
specs/ one directory per feature: spec.md, plan.md, tasks.md
See LICENSE.
hooks/register.tsx 2396 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type {
5 InboxDismissal,
6 InboxEntry,
7 InboxNeed,
8 InboxOption,
9 InboxPlace,
10 InboxQuestion,
11 InboxRemoteEntry,
12 InboxRun,
13} from '../types'
14
15// What waits on the owner, gathered across sessions, and only what the owner
16// can act on: an open question, a refused command whose refusal hands the act
17// to the person, and an owner signal the model filed through the owner_action
18// tool. Each session writes its own entries to one JSON file in a shared
19// folder under the home folder, and every pane reads the 200 newest files of
20// 256 KB or less. Entries read from disk are untrusted text: they are shown
21// and copied, never run. A remote card's Go to session press runs two
22// things: a fixed Windows PowerShell query of the process list (no card value
23// reaches it), and, when that shows the instance running, the app's launcher
24// with a link, at a folder rebuilt from this machine's own environment. Run
25// exists only for this session's own entries, read
26// from $.state at the moment the owner presses it. Only the newest remote
27// entries are held and drawn; the count still takes in every one.
28
29const PLUGIN = 'korus-inbox'
30const PANE = 'korus-inbox'
31const COMMAND = 'inbox'
32const FORMAT = 'korus-inbox/1'
33const FOLDER = '.korus-inbox'
34const ACTION_TOOL = 'owner_action'
35const DONE_TOOL = 'owner_action_done'
36// The full names the model calls. The engine names a plugin tool
37// `mcp__<plugin>__<name>`; session.start replaces these with the names
38// $.tool.register returns, so the match follows the engine and not this guess.
39const toolNames = { action: `mcp__${PLUGIN}__${ACTION_TOOL}`, done: `mcp__${PLUGIN}__${DONE_TOOL}` }
40// The desktop app's tool that renames a session; `session_id: 'self'` is this one.
41const RENAME_TOOL = 'mcp__ccd_session_mgmt__set_session_title'
42const DAY_MS = 24 * 60 * 60 * 1000
43const POLL_MS = 5000
44const HEARTBEAT_MS = 10 * 60 * 1000
45const ARM_MS = 60 * 1000
46// A confirm this soon after the arming press is ignored, so one double click
47// or a repeated Enter cannot arm and run in a single gesture.
48const ARM_GAP_MS = 600
49const MAX_FILE_BYTES = 256 * 1024
50const MAX_FILES = 200
51const MAX_ENTRIES = 50
52const MAX_COMMAND = 4000
53const MAX_DETAIL = 8000
54const MAX_DETAIL_SHARED = 1500
55const MAX_DISMISSED = 500
56const MAX_PENDING_REFUSALS = 200
57// The engine refuses a whole Pane over 100000 characters of text, and a
58// remote $.state update fails at about 4 MB. A remote card runs about 670
59// characters (150 of them blanked the pane), so 40 come to about 27000 and
60// leave over 70000 for this session's 50 own cards. The state holds the same
61// 40: at most about 20 KB each in JSON, so under 1 MB at worst.
62const MAX_REMOTE_SHOWN = 40
63// A card can run far past 670: a question to 6000 characters, and Details
64// open adds the command and refusal. So the remote cards also stop at this
65// much text, counted generously, whatever their number.
66const REMOTE_TEXT_BUDGET = 50_000
67const RUN_TIMEOUT_MS = 5 * 60 * 1000
68const TAIL_LINES = 15
69const HEAD_CUT = 80
70const BASH_ON_WINDOWS =
71 'Copy only: on Windows, Run is offered only for PowerShell. Git Bash can expand a Bash command differently from what the card shows.'
72
73const own = atom({ plugin: 'korus-inbox', key: 'own' } as const, [])
74// The newest remote entries and the count of all of them, in one atom, so
75// one write moves both and no render pairs a new list with an old total.
76const remote = atom({ plugin: 'korus-inbox', key: 'remoteHeld' } as const, { entries: [], total: 0 })
77const dismissed = atom({ plugin: 'korus-inbox', key: 'dismissed' } as const, [])
78const folderNote = atom({ plugin: 'korus-inbox', key: 'folderNote' } as const, null)
79const expanded = atom({ plugin: 'korus-inbox', key: 'expanded' } as const, [])
80const title = atom({ plugin: 'korus-inbox', key: 'title' } as const, null)
81
82type Engine = EngineInterface
83
84// ---------------------------------------------------------------- text rules
85
86// Control characters (ANSI escapes included), zero-width and bidirectional
87// marks, and Unicode line separators are stripped from every string shown or
88// copied, so a file cannot repaint the terminal, hide text, or reorder what
89// the owner reads. Newlines and tabs stay, and every line is drawn.
90const CONTROL =
91 /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u00ad\u034f\u061c\u180e\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufe00-\ufe0f\ufeff\ufff9-\ufffb\u{e0000}-\u{e007f}]/gu
92
93function clean(value: unknown, max: number): string {
94 if (typeof value !== 'string') return ''
95 const text = value.replace(CONTROL, '')
96 return text.length > max ? `${text.slice(0, max)} [cut]` : text
97}
98
99function flat(text: string): string {
100 return text.replace(/\s+/g, ' ').trim()
101}
102
103function cut(text: string, max: number): string {
104 return text.length > max ? `${text.slice(0, max)} [cut]` : text
105}
106
107// A command or refusal that looks like it carries a secret is never stored:
108// a placeholder takes its place. It errs toward withholding: a false match
109// costs the owner a Copy, a missed one writes the secret to a shared file.
110const SECRET_WORDS =
111 /token|passw|secret|credential|api[_-]?key|bearer\s|authorization:|sshpass|asplaintext|convertto-securestring|(key|pass|pwd|pw)\s*[=:]/i
112const URL_CREDENTIALS = /[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:[^\s/@]+@/i
113const BASIC_AUTH = /(^|\s)(-u\s*|--user[=\s]+)\S+:\S+/
114const MYSQL_PASSWORD = /\bmysql\w*\b.*\s-p\S/i
115const KNOWN_KEYS = /\b(AKIA|ASIA)[A-Z0-9]{16}\b|\b(ghp|gho|ghs|ghu|github_pat|sk|xox[abp])[-_][A-Za-z0-9_-]{10,}/
116const HEX_RUN = /[0-9a-f]{32,}/i
117const LONG_RUN = /[A-Za-z0-9+/_=-]{32,}/g
118
119function looksSecret(text: string): boolean {
120 if (SECRET_WORDS.test(text) || URL_CREDENTIALS.test(text) || BASIC_AUTH.test(text)) return true
121 if (MYSQL_PASSWORD.test(text) || KNOWN_KEYS.test(text)) return true
122 if (HEX_RUN.test(text)) return true
123 for (const run of text.match(LONG_RUN) ?? []) {
124 // A path is a long run too; a key has digits and both cases in it.
125 if (/[0-9]/.test(run) && /[a-z]/.test(run) && /[A-Z]/.test(run)) return true
126 }
127 return false
128}
129
130// Questions are prose, where "token" and "key" are ordinary words, so they
131// are held to the shapes that carry a value rather than to the words.
132function questionLooksSecret(text: string): boolean {
133 return (
134 /(token|password|passwd|secret|key|pass|pwd)\s*[=:]\s*\S/i.test(text) ||
135 URL_CREDENTIALS.test(text) ||
136 KNOWN_KEYS.test(text) ||
137 HEX_RUN.test(text) ||
138 (text.match(LONG_RUN) ?? []).some(run => /[0-9]/.test(run) && /[a-z]/.test(run) && /[A-Z]/.test(run))
139 )
140}
141
142const WITHHELD_SECRET = 'command withheld: it looked like it carried a secret'
143const WITHHELD_LONG = `command withheld: longer than ${MAX_COMMAND} characters`
144const REFUSAL_SECRET = 'refusal text withheld: it looked like it carried a secret'
145const QUESTION_SECRET = '(text withheld: it looked like it carried a secret)'
146const NO_ACTION = 'No recommended action recorded.'
147
148// The text a session's tool result starts with when a settings PreToolUse
149// hook blocked the call (seen 2026-10-02: "PreToolUse:PowerShell hook error:
150// BLOCKED: ..."). The classic.PreToolUse hook below is the primary signal;
151// this is the fallback for a refusal that reached the result some other way.
152const HOOK_REFUSAL = /^\s*PreToolUse:(Bash|PowerShell) hook\b/
153
154// The prefixes a refusal carries before its reason: the hook wrapper and the
155// gate's own BLOCKED or DENIED.
156const REFUSAL_PREFIX = /^\s*(PreToolUse:\w+ hook( error| blocked)?\s*:|BLOCKED\s*:|DENIED\s*:|Error\s*:)\s*/i
157
158function stripPrefix(text: string): string {
159 let rest = text
160 for (let i = 0; i < 4; i++) {
161 const next = rest.replace(REFUSAL_PREFIX, '')
162 if (next === rest) break
163 rest = next
164 }
165 return rest
166}
167
168function firstLine(text: string): string {
169 const line = text.split(/\r?\n/).find(one => one.trim() !== '') ?? ''
170 return clean(line.trim(), 300)
171}
172
173// The refusal's reason as one short sentence: prefixes off, cut at the first
174// sentence end.
175function whyOf(text: string | undefined): string {
176 if (text === undefined || text === '') return ''
177 const body = flat(stripPrefix(text))
178 const end = body.search(/[.!?](\s|$)/)
179 return cut(end < 0 ? body : body.slice(0, end + 1), 200)
180}
181
182function lastLines(text: string, count: number): string {
183 const lines = text.replace(/\r\n/g, '\n').split('\n')
184 while (lines.length > 0 && lines[lines.length - 1]?.trim() === '') lines.pop()
185 return clean(lines.slice(-count).join('\n'), 3000)
186}
187
188function age(ms: number): string {
189 const s = Math.max(0, Math.round(ms / 1000))
190 if (s < 60) return `${s}s`
191 const m = Math.round(s / 60)
192 if (m < 60) return `${m}m`
193 const h = Math.floor(m / 60)
194 return `${h}h${String(m % 60).padStart(2, '0')}m`
195}
196
197function errorText(error: unknown): string {
198 return error instanceof Error ? error.message : String(error)
199}
200
201// ------------------------------------------------ what hands the act over
202
203// A refused command reaches the inbox only when its refusal hands the act to
204// the person. Each row names a phrase a gate prints and the step it maps to.
205// The order is priority: the first row that matches decides the step. Read
206// off the gates' own deny texts on 2026-10-02 (worktree_gate.ps1 rules 3,
207// 3b, 3d and 1a, and its announce/off row); a refusal no row matches is a
208// guard the agent routes around itself, and it is not recorded at all.
209type ActionRow = {
210 name: string
211 phrase: RegExp
212 act: (match: RegExpExecArray, target: Target) => string
213}
214
215// What the step names: the blocked part, when it is first on its line, or else
216// the whole command. A part that is not first is never offered alone, because
217// alone it could run in a different context from the line it came from.
218type Target = { part: string } | { whole: string }
219
220const WHOLE = 'run the whole command in a plain terminal: '
221
222function step(target: Target, partLead: string, wholeLead: string): string {
223 return 'part' in target ? `${partLead}${target.part}` : `${wholeLead}${target.whole}`
224}
225
226const ACTION_ROWS: readonly ActionRow[] = [
227 {
228 name: 'I need you to confirm',
229 phrase: /\bI\s+need\s+you\s+to\s+confirm\b([^."\n]*)/i,
230 act: (match, target) =>
231 `Confirm${match[1] !== undefined && match[1].trim() !== '' ? ` ${flat(match[1])}` : ' it is safe'}, then ${step(target, 'run: ', WHOLE)}`,
232 },
233 {
234 name: 'from a PLAIN terminal',
235 phrase: /\bplain\s+terminal\b/i,
236 act: (match, target) => step(target, 'Run this in a plain terminal: ', 'Run the whole command in a plain terminal: '),
237 },
238 {
239 name: 'governs agents, not you',
240 phrase: /\bgoverns\s+agents,?\s+not\s+you\b/i,
241 act: (match, target) => step(target, 'Run this in a plain terminal: ', 'Run the whole command in a plain terminal: '),
242 },
243 {
244 name: "the user's call",
245 phrase: /\buser['\u2019]?s\s+call\b/i,
246 act: (match, target) => `Decide; if you agree, ${step(target, 'run it yourself: ', WHOLE)}`,
247 },
248 {
249 name: 'a human act',
250 phrase: /\bhuman\s+act\b/i,
251 act: (match, target) => step(target, 'Do it yourself, from a plain terminal: ', `Do it yourself: ${WHOLE}`),
252 },
253 {
254 name: 'only the owner',
255 phrase: /\bonly\s+the\s+owner\b/i,
256 act: (match, target) => `Only you can do this; if you agree, ${step(target, 'run it yourself: ', WHOLE)}`,
257 },
258 {
259 name: 'let the user decide',
260 phrase: /\blet\s+the\s+user\s+decide\b/i,
261 act: () => 'Decide what the session asks under "For you".',
262 },
263 {
264 name: 'ask the user',
265 phrase: /\bask\s+the\s+user\b/i,
266 act: () => 'Answer what the session asks under "For you".',
267 },
268 {
269 name: 'I need you to',
270 phrase: /\bI\s+need\s+you\s+to\b/i,
271 act: () => 'Do what the session asks under "For you".',
272 },
273]
274
275type Handover = { row: ActionRow; match: RegExpExecArray }
276
277function handover(text: string | undefined): Handover | undefined {
278 if (text === undefined) return undefined
279 for (const row of ACTION_ROWS) {
280 const match = row.phrase.exec(text)
281 if (match !== null) return { row, match }
282 }
283 return undefined
284}
285
286// A withheld ask keeps the row that let it in: the placeholder names the row,
287// and handover() reads the same row back from it. So withholding never changes
288// which step the card gives, and a reader keeps the entry by the same rule as
289// any other, rather than by an exception for the placeholder.
290function askWithheld(row: ActionRow): string {
291 return `ask withheld: it looked like it carried a secret. The gate handed it over as: ${row.name}`
292}
293
294// The ask as stored: withheld when it looks like a secret by either check.
295// The row is the one that let the entry in, so it is never lost to a cut.
296function guardAsk(ask: string, row: ActionRow): string {
297 return looksSecret(ask) || questionLooksSecret(ask) ? askWithheld(row) : ask
298}
299
300// The refusal's sentence around the phrase that hands the act over.
301function sentenceAround(text: string, at: number, length: number): string {
302 let start = 0
303 for (const m of text.slice(0, at).matchAll(/[.!?]\s|:\n|\n[ \t]*\*[ \t]|\n[ \t]*\n/g)) start = m.index + m[0].length
304 const after = text.slice(at + length)
305 const end = after.search(/[.!?](\s|$)|:\n|\n[ \t]*\n/)
306 const tail = end < 0 ? after : after.slice(0, end + 1)
307 return cut(flat(`${text.slice(start, at + length)}${tail}`).replace(/^\*\s*/, ''), 240)
308}
309
310// ------------------------------------------------- the blocked part of a line
311
312// The first single-quoted span in the refusal. A quote inside a word (don't)
313// opens nothing, so a contraction cannot start a span.
314const QUOTED = /(?:^|[^A-Za-z0-9])'([^']+)'(?![A-Za-z0-9])/
315
316// What may follow the part: the line's end or a separator. A pipe after it is
317// fine, since the part alone still runs as it would have.
318const AFTER_OK = /^[ \t]*($|;|&&|\|\||\|(?!\|)|\r?\n)/
319
320type Shell = string | undefined
321
322// True only when the shell would read this text plainly, ending at top level:
323// outside every quote, with no comment, here-doc, here-string, redirect,
324// subshell, brace group, call or background operator, escape or line
325// continuation anywhere in it. A shape it cannot read for sure counts as not
326// plain, so the part is not used and the card falls back to Copy of the whole
327// line. Bash and PowerShell differ on escapes; an unknown shell gets both rules.
328function isPlain(text: string, shell: Shell): boolean {
329 const isBash = shell !== 'PowerShell'
330 // PowerShell reads typographic quotes as quotes, and after `--%` it passes
331 // the rest of the line through verbatim, so neither can be read plainly.
332 if (/[^\x00-\x7f]/.test(text) || text.includes('--%')) return false
333 let quote = ''
334 for (let i = 0; i < text.length; i++) {
335 const c = text[i] ?? ''
336 if (quote === "'") {
337 if (c === "'") quote = ''
338 continue
339 }
340 if (quote === '"') {
341 if (c === '`' || c === '$' || (isBash && c === '\\')) return false
342 if (c === '"') quote = ''
343 continue
344 }
345 if (c === "'" || c === '"') {
346 if (text[i - 1] === '$' || text[i - 1] === '@') return false
347 quote = c
348 continue
349 }
350 if ('#{}()<>`'.includes(c)) return false
351 if (isBash && c === '\\') return false
352 if (c === '&' && text[i + 1] !== '&' && text[i - 1] !== '&') return false
353 }
354 return quote === ''
355}
356
357// The separators a plain text uses outside its quotes. Only for text isPlain
358// accepted, where every quote closes and nothing escapes one.
359function separatorsOf(text: string): string[] {
360 return text.replace(/'[^']*'|"[^"]*"/g, '').match(/&&|\|\||[;|\n]/g) ?? []
361}
362
363const NO_PART =
364 'Copy only: the refusal does not quote one whole part of this line, so Copy takes the whole line and Run is not offered.'
365const NOT_FIRST = 'Copy only: other commands come before this part, so it could run in a different context.'
366
367// The blocked part, or why there is none to use. An allow-list, not a list of
368// what to refuse: the part counts only when it is the FIRST command on the
369// line, with nothing at all before it. Anything before it, even a plain `&&`
370// chain, could change the folder, the environment, a variable or whether the
371// part ran at all, and no list of such words has ever been complete.
372type Part = { part?: string; whyNot?: string }
373
374function partOf(command: string | undefined, refusal: string | undefined, shell: Shell): Part {
375 if (command === undefined || refusal === undefined) return { whyNot: NO_PART }
376 const span = QUOTED.exec(refusal)?.[1]
377 if (span === undefined || span.trim() === '' || span !== span.trim()) return { whyNot: NO_PART }
378 if (command.startsWith(span) && isPlain(span, shell) && AFTER_OK.test(command.slice(span.length))) return { part: span }
379 return { whyNot: command.indexOf(span) > 0 ? NOT_FIRST : NO_PART }
380}
381
382// ---------------------------------------------------------- the shared folder
383
384let folderCache: { dir: string; sep: string } | null | undefined
385
386async function folder($: Engine): Promise<{ dir: string; sep: string } | null> {
387 if (folderCache !== undefined) return folderCache
388 const profile = await $.env.get('USERPROFILE')
389 const home = profile !== undefined && profile !== '' ? profile : await $.env.get('HOME')
390 if (home === undefined || !(/^[A-Za-z]:[\\/]/.test(home) || home.startsWith('/'))) {
391 folderCache = null
392 return null
393 }
394 const sep = home.includes('\\') ? '\\' : '/'
395 folderCache = { dir: `${home.replace(/[\\/]+$/, '')}${sep}${FOLDER}`, sep }
396 return folderCache
397}
398
399const SAFE_STEM = /^[A-Za-z0-9_-]{1,100}$/
400
401// An entry is done when a Run of it exited 0, or it resolved without one (the
402// same part later ran fine in this session, or its filer said so). A failed
403// Run, or one that exited non-zero, leaves it waiting.
404function isDone(entry: InboxEntry): boolean {
405 if (entry.kind === 'question') return false
406 if (entry.resolvedAt !== undefined) return true
407 return entry.run?.status === 'done' && entry.run.exitCode === 0
408}
409
410function isWaiting(entry: InboxEntry): boolean {
411 return !isDone(entry)
412}
413
414// THE ONE COUNT. The band, the pane header, the waiting list and /inbox all
415// take their numbers from this, so the three never disagree. The remote list
416// holds only the newest entries; its total counts every one waiting, so an
417// entry not held or not drawn still counts.
418function waitingOf(
419 mine: readonly InboxEntry[],
420 theirs: { readonly entries: readonly InboxRemoteEntry[]; readonly total: number },
421): { own: InboxEntry[]; remote: InboxRemoteEntry[]; remoteCount: number; count: number } {
422 const waitingOwn = mine.filter(isWaiting)
423 return { own: waitingOwn, remote: [...theirs.entries], remoteCount: theirs.total, count: waitingOwn.length + theirs.total }
424}
425
426async function waiting($: Engine): Promise<ReturnType<typeof waitingOf>> {
427 return waitingOf(await read($, own), await read($, remote))
428}
429
430let writeChain: Promise<void> = Promise.resolve()
431let lastWritten = ''
432// Sessions whose ended file is written: a late write (a question's finally,
433// a heartbeat) must not bring them back with ended:false.
434const endedIds = new Set<string>()
435
436// The folder name: what a reader shows when the session has no title yet.
437async function label($: Engine): Promise<string> {
438 const root = await $.session.root().catch(() => '')
439 const base = root.split(/[\\/]/).filter(part => part !== '').pop() ?? 'session'
440 return clean(base, 60)
441}
442
443// The name the owner sees for this session in the app's session list. The
444// folder name and the session id are the background names, which the owner
445// cannot match to a session there, so the title wins whenever there is one.
446async function shownName($: Engine): Promise<string> {
447 return (await read($, title)) ?? (await label($))
448}
449
450const TITLE_MAX = 60
451
452// A title as one plain line, or '' when there is none to show. Flattened, so a
453// title cannot start a row of the card; checked for a secret before it is cut,
454// so a key cannot slip under the check by being cut short; and cut once. A
455// cut title is left as it is, so a reader's pass does not cut it again. Only a
456// value shape counts as a secret: words such as "token" are ordinary in a
457// title. The writer and every reader apply it, whoever wrote the file.
458function titleOf(raw: unknown): string {
459 const text = flat(clean(raw, 1000))
460 if (text === '' || questionLooksSecret(text)) return ''
461 return text.length > TITLE_MAX + ' [cut]'.length ? cut(text, TITLE_MAX) : text
462}
463
464// Keeps the newest title the engine handed over, and republishes when it
465// changed, so other sessions' cards name this one as the app now does. A title
466// that cannot be shown clears the one held: the app no longer shows that one.
467// No title at all (undefined) leaves the one held.
468async function noteTitle($: Engine, raw: unknown): Promise<void> {
469 if (raw === undefined) return
470 const shown = titleOf(raw)
471 const next = shown === '' ? null : shown
472 if (next === (await read($, title))) return
473 await update($, title, () => next)
474 void publish($)
475}
476
477// The title a classic event carries, or the one a hook beneath set in its
478// result, which wins as it does in the app. The app drops a hook's title when
479// the event is blocked, and ignores an empty one. A subagent's event never
480// names the session.
481async function titleFromEvent(
482 $: Engine,
483 e: { agent_id?: string; session_title?: string },
484 result: { block?: string; preventContinuation?: true; sessionTitle?: string },
485): Promise<void> {
486 if (e.agent_id !== undefined) return
487 const isBlocked = result.block !== undefined || result.preventContinuation === true
488 const set = !isBlocked && typeof result.sessionTitle === 'string' && result.sessionTitle !== '' ? result.sessionTitle : e.session_title
489 await noteTitle($, set).catch(() => undefined)
490}
491
492// ------------------------------------------------- where the app shows it
493
494// Each extra instance of the desktop app runs with its own data folder,
495// %USERPROFILE%\.claude-desktop-N. A launch naming a running instance's
496// folder hands a claude:// link to that instance, which brings the session
497// forward, and exits. The default instance (%APPDATA%\Claude) is not offered:
498// a hand-off to it has not been measured.
499const APP_ID = /^local_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
500const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
501// The last segment of an instance folder, matched exactly: no trailing dot or
502// space, which Windows would resolve to the same folder under another name.
503const INSTANCE_NAME = /\\\.claude-desktop-\d{1,3}$/i
504// A file whose session wrote within this long is alive. A file dated further
505// ahead than this is not believed.
506const FRESH_MS = HEARTBEAT_MS + 2 * 60 * 1000
507const AHEAD_MS = 5 * 60 * 1000
508const LAUNCH_TIMEOUT_MS = 20_000
509const QUERY_TIMEOUT_MS = 15_000
510// A new session's record can lag its start, so its place is looked up again
511// at each poll for this many polls (about two minutes), then only at publish.
512const PLACE_TRIES = 24
513// The app's processes, one line each: its executable, a tab, its command line.
514// A fixed script: nothing from any file reaches it. Each field is printed as
515// the base64 of its UTF-16LE bytes, because Windows PowerShell writes plain
516// text to a pipe in the console code page with a best-fit fallback: a Kelvin
517// sign arrives as `K`, an en dash as `-` and a curly quote as `"`, so a line
518// the app reads one way would reach the reader as another.
519const PROCESS_QUERY =
520 "Get-CimInstance Win32_Process -Filter \"Name='claude.exe'\" | ForEach-Object { [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes([string]$_.ExecutablePath)) + \"`t\" + [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes([string]$_.CommandLine)) }"
521const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
522
523// A field the process query printed, decoded from the base64 of its UTF-16LE
524// bytes, or undefined when the field has any other shape. The pattern lets
525// `=` stand only as padding, where indexOf gives -1, so a -1 means no byte.
526// A U+FFFD is refused: the query's encoder writes a lone surrogate as one, so
527// the reader cannot tell what the app holds there.
528function fromUtf16Base64(field: string): string | undefined {
529 if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(field)) return undefined
530 const bytes: number[] = []
531 for (let i = 0; i < field.length; i += 4) {
532 const a = BASE64.indexOf(field.charAt(i))
533 const b = BASE64.indexOf(field.charAt(i + 1))
534 const c = BASE64.indexOf(field.charAt(i + 2))
535 const d = BASE64.indexOf(field.charAt(i + 3))
536 bytes.push((a << 2) | (b >> 4))
537 if (c >= 0) bytes.push(((b & 15) << 4) | (c >> 2))
538 if (d >= 0) bytes.push(((c & 3) << 6) | d)
539 }
540 if (bytes.length % 2 !== 0) return undefined
541 let text = ''
542 for (let i = 0; i < bytes.length; i += 2) text += String.fromCharCode((bytes[i] ?? 0) | ((bytes[i + 1] ?? 0) << 8))
543 return text.includes('\ufffd') ? undefined : text
544}
545
546// The shape a place must have to be offered at all. The folder is matched
547// against this machine's own folders only when the owner presses.
548function placeOf(raw: unknown): InboxPlace | undefined {
549 if (typeof raw !== 'object' || raw === null) return undefined
550 const { instance, id } = raw as Record<string, unknown>
551 if (typeof instance !== 'string' || typeof id !== 'string') return undefined
552 if (!APP_ID.test(id) || instance.length > 260 || /["\x00-\x1f]/.test(instance) || !INSTANCE_NAME.test(instance)) return undefined
553 return { instance, id }
554}
555
556// A place another session wrote is offered while that session is alive.
557function isPlaceFresh(at: number | undefined, now: number): boolean {
558 return at !== undefined && now - at <= FRESH_MS && at - now <= AHEAD_MS
559}
560
561// Only a found place is kept. A new session starts before the app has
562// written its id into the record, so a miss is looked up again.
563let placeCache: { sessionId: string; place: InboxPlace } | undefined
564let placeTries = 0
565
566// This session's place in the app, from what the app put in its environment,
567// and only when the app's own record of that session names this session. A
568// process a session starts inherits that environment, and the record keeps it
569// from claiming its parent's place. Undefined outside the desktop app.
570async function myPlace($: Engine): Promise<InboxPlace | undefined> {
571 const sessionId = await $.session.id()
572 if (placeCache?.sessionId === sessionId) return placeCache.place
573 let place: InboxPlace | undefined
574 const id = await $.env.get('CLAUDE_CODE_HOST_SESSION_ID')
575 const exec = await $.env.get('CLAUDE_CODE_EXECPATH')
576 const account = await $.env.get('CLAUDE_CODE_ACCOUNT_UUID')
577 const org = await $.env.get('CLAUDE_CODE_ORGANIZATION_UUID')
578 // Matched on the path as written, so the folder keeps its own spelling.
579 const instance = exec === undefined ? undefined : /^(.+)\\claude-code\\/i.exec(exec)?.[1]
580 const candidate = instance === undefined ? undefined : placeOf({ instance, id })
581 if (candidate !== undefined && account !== undefined && org !== undefined && UUID.test(account) && UUID.test(org)) {
582 const record = `${candidate.instance}\\claude-code-sessions\\${account}\\${org}\\${candidate.id}.json`
583 const text = await $.fs.read(record).catch(() => '')
584 try {
585 const parsed = JSON.parse(text) as { cliSessionId?: unknown }
586 if (parsed.cliSessionId === sessionId) place = candidate
587 } catch {
588 // Not written yet, or mid-write: looked up again later.
589 }
590 }
591 if (place !== undefined) placeCache = { sessionId, place }
592 return place
593}
594
595// Looks this session's place up again while it is still missing, for a
596// while after start, and publishes it the moment it is found.
597async function retryPlace($: Engine): Promise<void> {
598 if (placeCache !== undefined || placeTries >= PLACE_TRIES) return
599 placeTries += 1
600 if ((await myPlace($)) !== undefined) void publish($)
601}
602
603// The folder a place names, rebuilt from this machine's own environment, or
604// undefined when it names no instance folder here. The file's spelling is
605// never launched: only the folder built here is.
606async function instanceFolder($: Engine, instance: string): Promise<string | undefined> {
607 const home = (await $.env.get('USERPROFILE'))?.replace(/\\+$/, '')
608 const numbered = /\\\.claude-desktop-(\d{1,3})$/i.exec(instance)
609 if (home === undefined || home === '' || numbered === null) return undefined
610 const built = `${home}\\.claude-desktop-${numbered[1]}`
611 return asciiLower(built) === asciiLower(instance) ? built : undefined
612}
613
614// Lowercases A to Z only. Full Unicode lowercasing folds some other letters
615// into ASCII, such as the Kelvin sign into `k`, and Windows keeps a folder
616// spelled with one apart from a folder spelled with the other. Chromium
617// lowercases switch names this way too.
618const asciiLower = (text: string): string => text.replace(/[A-Z]+/g, upper => upper.toLowerCase())
619
620// Strips the whitespace Chromium's TrimWhitespace strips on Windows, the set
621// kWhitespaceWide in base/strings/whitespace_constants.h. It is not what
622// String.prototype.trim strips: that keeps U+0085 and strips U+FEFF.
623const SPACE = /[\t\n\v\f\r \u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]/.source
624const EDGE_SPACE = new RegExp(`^${SPACE}+|${SPACE}+$`, 'g')
625const LEADING_SPACE = new RegExp(`^${SPACE}+`)
626const trimmed = (text: string): string => text.replace(EDGE_SPACE, '')
627
628const isBlank = (ch: string | undefined): boolean => ch === ' ' || ch === '\t'
629
630// A command line split into its arguments the way CommandLineToArgvW splits
631// it, which is how the app reads its own. A quoted program name runs to the
632// next quote, with no escapes. An unquoted one ends at the first character
633// from U+0001 to U+0020, not only at a blank, and that one character is
634// dropped. After the name, blanks outside quotes split, and
635// backslashes are literal unless a quote follows them. Counting the quote
636// that opened the quoting, every third quote in a run is a literal quote, and
637// a run that ends on the second closes the quoting. Windows quotes an
638// argument that holds a space, the whole argument or a part of it, so a home
639// folder with a space shows up quoted either way.
640function argsOf(commandLine: string): string[] {
641 const n = commandLine.length
642 let i = 0
643 let program = ''
644 if (commandLine[0] === '"') {
645 for (i = 1; i < n && commandLine[i] !== '"'; i += 1) program += commandLine[i]
646 i += 1
647 } else {
648 for (; i < n && commandLine[i] > ' '; i += 1) program += commandLine[i]
649 if (i < n) i += 1
650 }
651 const args = [program]
652 while (isBlank(commandLine[i])) i += 1
653 let arg = ''
654 let isArg = i < n
655 let quotes = 0
656 let slashes = 0
657 while (i < n) {
658 const ch = commandLine[i]
659 if (isBlank(ch) && quotes === 0) {
660 args.push(arg)
661 arg = ''
662 slashes = 0
663 while (isBlank(commandLine[i])) i += 1
664 isArg = i < n
665 continue
666 }
667 i += 1
668 if (ch === '\\') {
669 arg += ch
670 slashes += 1
671 continue
672 }
673 if (ch !== '"') {
674 arg += ch
675 slashes = 0
676 continue
677 }
678 if (slashes % 2 === 0) {
679 arg = arg.slice(0, arg.length - slashes / 2)
680 quotes += 1
681 } else {
682 arg = `${arg.slice(0, arg.length - (slashes + 1) / 2)}"`
683 }
684 slashes = 0
685 for (; commandLine[i] === '"'; i += 1) {
686 quotes += 1
687 if (quotes === 3) {
688 arg += '"'
689 quotes = 0
690 }
691 }
692 if (quotes === 2) quotes = 0
693 }
694 if (isArg) args.push(arg)
695 return args
696}
697
698// A switch as the app reads one on Windows: `--`, `-` or `/` before its name,
699// the name in any case of A to Z, and its value after the first `=`.
700function switchOf(arg: string): { name: string; value: string } | undefined {
701 const prefix = /^(--|-|\/)/.exec(arg)?.[0]
702 if (prefix === undefined || prefix.length === arg.length) return undefined
703 const equals = arg.indexOf('=')
704 return equals < 0
705 ? { name: asciiLower(arg.slice(prefix.length)), value: '' }
706 : { name: asciiLower(arg.slice(prefix.length, equals)), value: arg.slice(equals + 1) }
707}
708
709// Whether a command line is a main process of the app (no type switch) whose
710// data folder is this one. The app trims whitespace off the front of its
711// command line before it splits it, then trims each argument before it looks
712// for `--` or a switch, so this does too (Chromium base/command_line.cc:
713// ParseFromString and AppendSwitchesAndArguments). ParseFromString trims both
714// ends, but hands CommandLineToArgvW a pointer that still runs to the end of
715// the line, so only the front trim takes effect. The app reads its line as a
716// C string, so it stops at the first NUL. Every data-folder switch must name
717// the folder, though the app takes the last. A line that could stop the app
718// reading switches part way does not count: one with a bare `--`, one whose
719// raw text holds `single-argument` anywhere, in any case, or one with a
720// switch of that name. Quotes can split the raw text, as in
721// --single-argume""nt, so the parsed names are checked as well.
722function holdsFolder(commandLine: string, dataDir: string): boolean {
723 if (commandLine.toLowerCase().includes('single-argument')) return false
724 const line = (commandLine.split('\0', 1)[0] ?? '').replace(LEADING_SPACE, '')
725 const args = argsOf(line).slice(1).map(trimmed)
726 if (args.includes('--')) return false
727 const switches = args.map(switchOf).filter(one => one !== undefined)
728 if (switches.some(one => one.name === 'type' || one.name === 'single-argument')) return false
729 const folders = switches.filter(one => one.name === 'user-data-dir')
730 return folders.length > 0 && folders.every(one => asciiLower(one.value) === asciiLower(dataDir))
731}
732
733// Whether an instance of the app runs with this data folder, read from the
734// process list. A file in the folder would not do: whoever can write an inbox
735// file can plant one, and a launch into a folder no instance holds starts the
736// app on it. Only a main process counts (no --type), from the app's install.
737async function isInstanceRunning($: Engine, dataDir: string, install: string): Promise<boolean> {
738 const systemRoot = (await $.env.get('SystemRoot'))?.replace(/\\+$/, '')
739 if (systemRoot === undefined || systemRoot === '') return false
740 const shell = `${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`
741 const listed = await $.process.run([shell, '-NoProfile', '-NonInteractive', '-Command', PROCESS_QUERY], {
742 timeoutMs: QUERY_TIMEOUT_MS,
743 })
744 // A list cut at the cap can end in a line cut at a base64 group, which
745 // decodes to the front of the real line and could name a shorter folder.
746 if (listed.exitCode !== 0 || listed.isStdoutTruncated) return false
747 const prefix = asciiLower(`${install}\\`)
748 return listed.stdout.split(/\r?\n/).some(line => {
749 const fields = line.split('\t')
750 if (fields.length !== 2) return false
751 const exe = fromUtf16Base64(fields[0] ?? '')
752 const commandLine = fromUtf16Base64(fields[1] ?? '')
753 if (exe === undefined || commandLine === undefined) return false
754 return asciiLower(exe).startsWith(prefix) && holdsFolder(commandLine, dataDir)
755 })
756}
757
758const launching = new Set<string>()
759// The keys whose Go to session was fresh at the last poll.
760let lastFresh = ''
761
762// Brings another session forward in its own, running instance of the app.
763// It does not start an instance: one is launched only when the process list
764// shows it running, so the launch hands the link over. An instance that exits
765// between the check and the launch is the gap. No shell: each value is one
766// argument. The app is the install's own launcher, which outlives app updates
767// and exits as soon as it has handed the link over. It lives under
768// LOCALAPPDATA, where the user can write, because the app installs there.
769async function goTo($: Engine, key: string, raw: InboxPlace | undefined, at: number | undefined): Promise<void> {
770 if (launching.has(key)) return
771 launching.add(key)
772 try {
773 const place = placeOf(raw)
774 if (place === undefined || !isPlaceFresh(at, await $.clock.now())) {
775 $.ui.toast('Go to session: that session has gone quiet, so its app window may be closed.')
776 return
777 }
778 const dataDir = await instanceFolder($, place.instance)
779 const appData = (await $.env.get('LOCALAPPDATA'))?.replace(/\\+$/, '')
780 if (dataDir === undefined || appData === undefined || appData === '') {
781 $.ui.toast('Go to session: that session names no app instance on this machine.')
782 return
783 }
784 const install = `${appData}\\AnthropicClaude`
785 const app = `${install}\\claude.exe`
786 if (!(await $.fs.exists(app).catch(() => false))) {
787 $.ui.toast(clean(`Go to session: the app was not found at ${app}.`, 200))
788 return
789 }
790 if (!(await isInstanceRunning($, dataDir, install))) {
791 $.ui.toast('Go to session: that app instance is not running.')
792 return
793 }
794 const ran = await $.process.run([app, `--user-data-dir=${dataDir}`, `claude://claude.ai/epitaxy/${place.id}`], {
795 timeoutMs: LAUNCH_TIMEOUT_MS,
796 })
797 if (ran.exitCode !== 0) $.ui.toast(`Go to session: the app exited ${ran.exitCode}.`)
798 } catch (error: unknown) {
799 $.ui.toast(clean(`Go to session did not open it: ${errorText(error)}`, 200))
800 } finally {
801 launching.delete(key)
802 }
803}
804
805// Writes this session's file, queued so two writes never interleave. $.fs has
806// no rename and no delete, so the write is in place and whole; a reader that
807// catches it half-written fails to parse it and reads it again next poll.
808function publish($: Engine, options: { ended?: string; heartbeat?: boolean } = {}): Promise<void> {
809 writeChain = writeChain
810 .then(async () => {
811 const place = await folder($)
812 if (place === null) return
813 const sessionId = options.ended ?? (await $.session.id())
814 if (!SAFE_STEM.test(sessionId)) return
815 if (options.ended === undefined && endedIds.has(sessionId)) return
816 if (options.ended !== undefined) endedIds.add(sessionId)
817 const path = `${place.dir}${place.sep}${sessionId}.json`
818 const now = await $.clock.now()
819 const entries = options.ended !== undefined ? [] : (await read($, own)).filter(isWaiting)
820 const gone = options.ended !== undefined ? [] : await read($, dismissed)
821 const body = {
822 format: FORMAT,
823 sessionId,
824 label: await label($),
825 title: (await read($, title)) ?? undefined,
826 // An ended file names no place: the id it was for is gone.
827 place: options.ended !== undefined ? undefined : await myPlace($).catch(() => undefined),
828 updatedAt: now,
829 ended: options.ended !== undefined,
830 // Run output is never written: only what the owner must act on.
831 entries: entries.map(entry => ({
832 id: entry.id,
833 kind: entry.kind,
834 createdAt: entry.createdAt,
835 questions: entry.questions,
836 shell: entry.shell,
837 command: entry.command === undefined ? undefined : clean(entry.command, MAX_COMMAND),
838 withheld: entry.withheld,
839 cwd: entry.cwd,
840 refusal: entry.refusal,
841 detail: entry.detail === undefined ? undefined : cut(entry.detail, MAX_DETAIL_SHARED),
842 ask: entry.ask,
843 viaOutput: entry.viaOutput,
844 title: entry.title,
845 why: entry.why,
846 recommendedAction: entry.recommendedAction,
847 needs: entry.needs,
848 reviewOutcome: entry.reviewOutcome,
849 confidence: entry.confidence,
850 })),
851 dismissed: gone.filter(one => now - one.at < DAY_MS).map(one => one.key),
852 }
853 const isEmpty = body.entries.length === 0 && body.dismissed.length === 0 && !body.ended
854 if (isEmpty && lastWritten === '' && !(await $.fs.exists(path).catch(() => false))) return
855 const text = JSON.stringify(body)
856 const stamp = text.replace(/"updatedAt":\d+,/, '')
857 if (stamp === lastWritten && options.ended === undefined && options.heartbeat !== true) return
858 await $.fs.write(path, text)
859 lastWritten = stamp
860 if ((await read($, folderNote)) !== null) await update($, folderNote, () => null)
861 })
862 .catch(async (error: unknown) => {
863 const note = clean(`could not write the shared folder: ${errorText(error)}`, 200)
864 await update($, folderNote, () => note).catch(() => undefined)
865 })
866 return writeChain
867}
868
869// ------------------------------------------------------------ reading others
870
871function guardProse(text: string): string {
872 return questionLooksSecret(text) ? QUESTION_SECRET : text
873}
874
875function parseQuestions(value: unknown): InboxQuestion[] {
876 if (!Array.isArray(value)) return []
877 return value.slice(0, 4).flatMap((one: unknown): InboxQuestion[] => {
878 if (typeof one !== 'object' || one === null) return []
879 const q = one as Record<string, unknown>
880 const options = Array.isArray(q.options) ? q.options : []
881 return [
882 {
883 header: guardProse(clean(q.header, 60)),
884 question: guardProse(clean(q.question, 1000)),
885 options: options.slice(0, 6).flatMap((opt: unknown): InboxOption[] => {
886 if (typeof opt !== 'object' || opt === null) return []
887 const o = opt as Record<string, unknown>
888 return [{ label: guardProse(clean(o.label, 100)), description: guardProse(clean(o.description, 300)) }]
889 }),
890 },
891 ]
892 })
893}
894
895const NEEDS: readonly InboxNeed[] = ['preference', 'authority', 'private-context', 'cost']
896
897function asNeed(value: unknown): InboxNeed | undefined {
898 return NEEDS.find(one => one === value)
899}
900
901function prose(value: unknown, max: number): string | undefined {
902 if (typeof value !== 'string') return undefined
903 const text = clean(value, max)
904 return text === '' ? undefined : guardProse(text)
905}
906
907// A signal's prose gets the full secret check, the one a command gets: a
908// recommended action can carry `-p<password>` or `curl -u user:pass` as
909// easily as a command can. A hit stores the placeholder for that field alone.
910function signalProse(text: string | undefined): string | undefined {
911 return text !== undefined && looksSecret(text) ? QUESTION_SECRET : text
912}
913
914type ParsedFile = { entries: InboxRemoteEntry[]; dismissed: string[] }
915
916const NOTHING: ParsedFile = { entries: [], dismissed: [] }
917
918// Everything here is untrusted: each field is checked for type, cut to a
919// length, stripped of control characters, and a command that looks like a
920// secret is withheld even though the writing session should have done so.
921// Undefined means the text did not parse (a write in flight); a file that
922// parsed but is ended, stale or of another format reads as holding nothing.
923// A refused entry whose own words do not hand the act to the person is not
924// actionable, so it is dropped here too, whoever wrote it.
925function parseFile(stem: string, text: string, now: number): ParsedFile | undefined {
926 let raw: unknown
927 try {
928 raw = JSON.parse(text)
929 } catch {
930 return undefined
931 }
932 if (typeof raw !== 'object' || raw === null) return NOTHING
933 const file = raw as Record<string, unknown>
934 if (file.format !== FORMAT || file.ended === true) return NOTHING
935 if (typeof file.updatedAt !== 'number' || now - file.updatedAt > DAY_MS) return NOTHING
936 // The title the owner sees in the app's session list. A file with none, or
937 // from a version before titles, falls back to the folder and id prefix.
938 const shown = titleOf(file.title)
939 // Offered only while the asking session is alive, so its instance runs.
940 const place = placeOf(file.place)
941 const placeAt = place === undefined ? undefined : file.updatedAt
942 const sessionLabel = shown !== '' ? shown : `${flat(clean(file.label, 60)) || 'session'} ${stem.slice(0, 8)}`
943 const list = Array.isArray(file.entries) ? file.entries.slice(0, MAX_ENTRIES) : []
944 const entries = list.flatMap((one: unknown): InboxRemoteEntry[] => {
945 if (typeof one !== 'object' || one === null) return []
946 const e = one as Record<string, unknown>
947 if (typeof e.id !== 'string' || !/^[A-Za-z0-9_.-]{1,100}$/.test(e.id)) return []
948 if (e.kind !== 'question' && e.kind !== 'refused' && e.kind !== 'signal') return []
949 if (typeof e.createdAt !== 'number' || !Number.isFinite(e.createdAt)) return []
950 if (now - e.createdAt > DAY_MS || e.createdAt - now > 5 * 60 * 1000) return []
951 let command = typeof e.command === 'string' ? clean(e.command, MAX_COMMAND) : undefined
952 let withheld = typeof e.withheld === 'string' ? clean(e.withheld, 120) : undefined
953 if (command !== undefined && looksSecret(command)) {
954 command = undefined
955 withheld = WITHHELD_SECRET
956 }
957 let refusal = typeof e.refusal === 'string' ? clean(e.refusal, 300) : undefined
958 if (refusal !== undefined && looksSecret(refusal)) refusal = REFUSAL_SECRET
959 let detail = typeof e.detail === 'string' ? clean(e.detail, MAX_DETAIL_SHARED) : undefined
960 if (detail !== undefined && looksSecret(detail)) detail = REFUSAL_SECRET
961 // The handover rule reads the ask as written, before any withholding, so
962 // a secret-looking ask is held to the same rule as any other. Withheld,
963 // it keeps the row that let it in.
964 const askText = typeof e.ask === 'string' ? clean(e.ask, 300) || undefined : undefined
965 const found = askText === undefined ? undefined : handover(askText)
966 if (e.kind === 'refused' && found === undefined) return []
967 const ask = askText === undefined || found === undefined ? undefined : guardAsk(askText, found.row)
968 return [
969 {
970 key: `${stem}:${e.id}`,
971 sessionLabel,
972 place,
973 placeAt,
974 kind: e.kind,
975 createdAt: e.createdAt,
976 questions: e.kind === 'question' ? parseQuestions(e.questions) : [],
977 shell: e.shell === 'Bash' || e.shell === 'PowerShell' ? e.shell : undefined,
978 command,
979 withheld,
980 cwd: typeof e.cwd === 'string' ? clean(e.cwd, 500) : undefined,
981 refusal,
982 detail,
983 ask,
984 viaOutput: e.viaOutput === true,
985 title: signalProse(prose(e.title, 200)),
986 why: signalProse(prose(e.why, 600)),
987 recommendedAction: signalProse(prose(e.recommendedAction, 400)),
988 needs: asNeed(e.needs),
989 reviewOutcome: signalProse(prose(e.reviewOutcome, 300)),
990 confidence: signalProse(prose(e.confidence, 120)),
991 },
992 ]
993 })
994 const gone = Array.isArray(file.dismissed)
995 ? file.dismissed
996 .slice(0, MAX_DISMISSED)
997 .filter((key): key is string => typeof key === 'string' && key.length <= 220)
998 : []
999 return { entries, dismissed: gone }
1000}
1001
1002const lastGood = new Map<string, { mtimeMs: number; parsed: ParsedFile }>()
1003
1004// The one way the pane opens, from /inbox and from the band's Button alike.
1005async function openInbox($: Engine): Promise<number> {
1006 await poll($).catch(() => undefined)
1007 await $.ui.open({ id: PANE, title: 'Owner inbox' })
1008 return (await waiting($)).count
1009}
1010
1011async function poll($: Engine): Promise<void> {
1012 const place = await folder($)
1013 if (place === null) {
1014 await update($, folderNote, () => 'no home folder in USERPROFILE or HOME; the shared folder is off')
1015 return
1016 }
1017 const me = await $.session.id()
1018 const now = await $.clock.now()
1019 const listing = await $.fs.list(place.dir).catch(() => [])
1020 const files = listing
1021 .filter(one => one.kind === 'file' && !one.isLink && one.name.endsWith('.json'))
1022 .filter(one => one.name !== `${me}.json` && now - one.mtimeMs < DAY_MS && one.size <= MAX_FILE_BYTES)
1023 .sort((a, b) => b.mtimeMs - a.mtimeMs)
1024 .slice(0, MAX_FILES)
1025 const found: InboxRemoteEntry[] = []
1026 const goneKeys = new Set((await read($, dismissed)).map(one => one.key))
1027 const seen = new Set<string>()
1028 for (const one of files) {
1029 const stem = one.name.slice(0, -'.json'.length)
1030 if (!SAFE_STEM.test(stem)) continue
1031 seen.add(stem)
1032 const cached = lastGood.get(stem)
1033 let parsed = cached !== undefined && cached.mtimeMs === one.mtimeMs ? cached.parsed : undefined
1034 if (parsed === undefined) {
1035 const text = await $.fs.read(`${place.dir}${place.sep}${one.name}`).catch(() => undefined)
1036 // The listing checked the size; a file can grow between the listing and the read.
1037 parsed = typeof text === 'string' && text.length <= MAX_FILE_BYTES ? parseFile(stem, text, now) : undefined
1038 if (parsed !== undefined) lastGood.set(stem, { mtimeMs: one.mtimeMs, parsed })
1039 else if (cached !== undefined) parsed = cached.parsed // a write in flight: keep the last good read
1040 else continue
1041 }
1042 for (const key of parsed.dismissed) goneKeys.add(key)
1043 found.push(...parsed.entries.filter(entry => now - entry.createdAt < DAY_MS))
1044 }
1045 for (const stem of [...lastGood.keys()]) if (!seen.has(stem)) lastGood.delete(stem)
1046
1047 const keys = new Set<string>()
1048 const visible = found
1049 .filter(entry => !goneKeys.has(entry.key) && !keys.has(entry.key) && keys.add(entry.key) !== undefined)
1050 .sort((a, b) => b.createdAt - a.createdAt)
1051 // Only the newest are held, so the update stays far under its size limit;
1052 // the total keeps the rest in the count.
1053 const held = { entries: visible.slice(0, MAX_REMOTE_SHOWN), total: visible.length }
1054 const current = await read($, remote)
1055 if (JSON.stringify(current) !== JSON.stringify(held)) await update($, remote, () => held)
1056 // A Go to session button goes stale with time alone, which no state write
1057 // marks, so the pane is redrawn when the set of fresh ones changes.
1058 const fresh = held.entries.filter(entry => entry.place !== undefined && isPlaceFresh(entry.placeAt, now)).map(entry => entry.key).join(' ')
1059 if (fresh !== lastFresh) {
1060 lastFresh = fresh
1061 $.ui.invalidate('ui.render')
1062 }
1063 await retryPlace($).catch(() => undefined)
1064
1065 // A Dismiss pressed in another pane clears one of this session's entries
1066 // here too. Any file can name one, so this only ever hides, never adds.
1067 // A running entry stays, so its result is not lost. Own entries age out at
1068 // the same 24 hours readers use, so every pane counts the same set.
1069 const isGone = (entry: InboxEntry): boolean =>
1070 entry.run?.status !== 'running' && (goneKeys.has(`${me}:${entry.id}`) || now - entry.createdAt >= DAY_MS)
1071 const mine = await read($, own)
1072 if (mine.some(isGone)) {
1073 await update($, own, list => list.filter(entry => !isGone(entry)))
1074 void publish($)
1075 }
1076}
1077
1078// ----------------------------------------------------------------- recording
1079
1080// Armed now: the Run press was less than a minute ago. A lapsed or cancelled
1081// arm clears armedAt, and this also reads the clock, so an arm whose clear was
1082// lost (a reload drops the timer) still counts as lapsed.
1083function isArmedAt(entry: InboxEntry, now: number): boolean {
1084 return entry.armedAt !== undefined && now - entry.armedAt < ARM_MS
1085}
1086
1087async function addOwn($: Engine, entry: InboxEntry): Promise<void> {
1088 const now = await $.clock.now()
1089 // Done entries go first when the list is full, so a waiting one is kept.
1090 await update($, own, list => {
1091 const next = [...list.filter(one => one.id !== entry.id), entry]
1092 // An entry running, or armed and not yet lapsed, is never the one dropped,
1093 // so a Run's result always has its entry to land on.
1094 const isBusy = (one: InboxEntry): boolean => one.run?.status === 'running' || isArmedAt(one, now)
1095 while (next.length > MAX_ENTRIES) {
1096 const doneAt = next.findIndex(one => isDone(one) && !isBusy(one))
1097 const at = doneAt >= 0 ? doneAt : next.findIndex(one => !isBusy(one))
1098 if (at < 0) break
1099 next.splice(at, 1)
1100 }
1101 return next
1102 })
1103 void publish($)
1104}
1105
1106async function removeOwn($: Engine, id: string): Promise<void> {
1107 await update($, own, list => list.filter(one => one.id !== id))
1108 void publish($)
1109}
1110
1111// The exact command is kept for Run; the shared file and the screen get it
1112// cleaned. Either spelling looking like a secret withholds it.
1113function captureCommand(command: string): { command?: string; withheld?: string } {
1114 if (looksSecret(command) || looksSecret(clean(command, MAX_COMMAND + 1))) return { withheld: WITHHELD_SECRET }
1115 if (command.length > MAX_COMMAND) return { withheld: WITHHELD_LONG }
1116 return { command }
1117}
1118
1119// A refused command that the same session later ran fine is no longer the
1120// owner's. Only when all of these hold: the entry's blocked part was first on
1121// its line; the entry and the later call both came from the main loop; the
1122// later call ran in the foreground and finished without error; it ran in the
1123// same shell and the same folder; and it STARTS with the part, followed by
1124// nothing or by `&&` alone, so its success is the part's own.
1125async function resolveBySuccess($: Engine, command: string, shell: Shell, cwd: string): Promise<void> {
1126 const mine = await read($, own)
1127 const ranFine = (part: string): boolean => {
1128 // A part with its own `;`, `||`, `|` or line break could fail inside and
1129 // still exit 0, so only a part joined by `&&` alone, or by nothing, counts.
1130 if (!command.startsWith(part) || !separatorsOf(part).every(one => one === '&&')) return false
1131 const rest = command.slice(part.length)
1132 if (rest.trim() === '') return true
1133 return /^[ \t]*&&/.test(rest) && isPlain(rest, shell) && separatorsOf(rest).every(one => one === '&&')
1134 }
1135 const matches = (one: InboxEntry): boolean => {
1136 if (one.kind !== 'refused' || one.agentId !== undefined || !isWaiting(one) || one.run?.status === 'running') return false
1137 if (one.shell !== shell || one.cwd !== clean(cwd, 500)) return false
1138 const part = partOf(one.command, one.detail, one.shell).part
1139 return part !== undefined && ranFine(part)
1140 }
1141 if (!mine.some(matches)) return
1142 const now = await $.clock.now()
1143 await update($, own, list =>
1144 list.map(one => (matches(one) ? { ...one, resolvedAt: now, resolvedBy: 'it later ran fine in this session' } : one)),
1145 )
1146 void publish($)
1147}
1148
1149// ----------------------------------------------------------------------- run
1150
1151// Run happens only in a folder known as a full path. A failed $.session.cwd()
1152// records an empty one, and an empty or relative cwd would run the command in
1153// whatever folder the host process happens to be in.
1154// On Windows only a drive path counts: `/x` there is relative to the current
1155// drive. A `//` or `\\` share path is refused everywhere.
1156function isAbsolutePath(path: string | undefined, isWindows?: boolean): path is string {
1157 if (path === undefined) return false
1158 if (/^[A-Za-z]:[\\/]/.test(path)) return true
1159 return isWindows !== true && path.startsWith('/') && !path.startsWith('//')
1160}
1161
1162async function onWindows($: Engine): Promise<boolean> {
1163 return (await $.env.get('ProgramFiles')) !== undefined || (await folder($))?.sep === '\\'
1164}
1165
1166// argv[0] as the owner reads it. A bare name is looked up by the process
1167// runner, and on Windows that lookup can try the run folder before PATH, so
1168// the screen says so rather than implying a known file.
1169function binaryText(binary: string): string {
1170 return /[\\/]/.test(binary) ? binary : `${binary} (by name: PATH, and on Windows the run folder first)`
1171}
1172
1173// The confirm view promises the owner reads exactly what runs. Text that soft-
1174// wraps can start a screen line with a forged `argv[N]:`, and a long text can
1175// bury its payload far from Run now. So Run is offered only for text the view
1176// can show faithfully, and the view draws every row itself, one Text each, cut
1177// at the edge rather than wrapped.
1178//
1179// RUN_CHUNK: the view breaks a line into rows of at most 40 characters. With a
1180// prefix of at most 12 (`Blocked: `, `argv[3]: `, ` line 2: `), a row needs
1181// at most RUN_COLUMNS, 52 cells. A pane narrower than that would cut rows
1182// short, so it offers no Run.
1183// MAX_RUN_CHARS: 400 characters is at most 10 such rows. With the line breaks
1184// below the whole text fits in about 16 rows, on one screen with Run now.
1185// MAX_RUN_LINES: 6 lines. A blocked part is one or two lines; 6 still lets a
1186// short script run, and more is a script the owner should read in an editor.
1187// SPACE_RUN: 4 or more spaces in a row. Padding is how a wrapped line forges a
1188// row start; an ordinary command rarely holds more than 2 together. No row
1189// wraps now, so this guards the rows' columns, not their starts.
1190// LABEL_LIKE: any label the views and the card draw: `argv[`, `folder:`,
1191// `line 2:`, `out:`, `error:`, a shell name and colon, and the card's own
1192// Blocked, Why, From, Command, Do this and For you, and a Run's own result
1193// headers: running, ran, exit N, could not run, Done and Run now. The rest
1194// of what the card draws counts too: argv:, Copy only:, Needs you for:,
1195// confidence:, Review:, Question: and Resolved. The cut
1196// falls at the same place on every pane, so a command could put `argv[4]: x` at the start of a
1197// ` + ` row and have it read as a new row. Text holding a label is Copy only.
1198// Each is matched anywhere, since the cut can fall right before it: so
1199// `stdout:` is Copy only too, while a bare `sys.argv` with no `[` still runs.
1200// RUN_ASCII: printable ASCII and newlines only. Every such character is onetypes/index.d.ts 135 lines1// The korus-inbox contract: what the pane draws from, held in $.state.
2
3/** One option of a pending question, as the model offered it. */
4export type InboxOption = { label: string; description: string }
5
6/** One question of a pending AskUserQuestion call. */
7export type InboxQuestion = { header: string; question: string; options: InboxOption[] }
8
9/** Why an entry needs the owner, on the driver ladder: only these four reach the owner. */
10export type InboxNeed = 'preference' | 'authority' | 'private-context' | 'cost'
11
12/** The result of a Run the owner pressed. Held in this session only, never written to disk. */
13export type InboxRun = {
14 status: 'running' | 'done' | 'failed'
15 startedAt: number
16 finishedAt?: number
17 exitCode?: number
18 /** The exact argv that ran, or would have; absent when no shell was found. */
19 argv?: string[]
20 tail?: string
21}
22
23/**
24 * One entry this session raised: an open question, a refused command whose
25 * refusal hands the act to the person, or an owner signal the model filed.
26 * `command` is the exact text, kept only in this session's $.state; it is
27 * absent when it looked like it carried a secret, or was too long to keep, and
28 * `withheld` says why.
29 */
30export type InboxEntry = {
31 id: string
32 kind: 'question' | 'refused' | 'signal'
33 createdAt: number
34 agentId?: string
35 questions?: InboxQuestion[]
36 shell?: 'Bash' | 'PowerShell'
37 command?: string
38 withheld?: string
39 cwd?: string
40 /** The refusal's first line. */
41 refusal?: string
42 /** The whole refusal, cleaned and cut; the blocked part is read from it. */
43 detail?: string
44 /** The refusal's sentence that hands the act to the person. */
45 ask?: string
46 /** True when the refusal was read from the call's result text, not the PreToolUse decision: the gate is not verified. */
47 viaOutput?: boolean
48 /** An owner signal's own fields, as the model filed them, cleaned and cut. */
49 title?: string
50 why?: string
51 recommendedAction?: string
52 needs?: InboxNeed
53 reviewOutcome?: string
54 confidence?: string
55 /** True for a main-loop entry in a folder known as a full path; anything else is Copy only. */
56 isRunnable?: boolean
57 /** Why an entry is Copy only, worked out when it was recorded. */
58 copyOnly?: string
59 /** When the owner pressed Run; Run now is offered for a minute after it, and ignores a press within 600 ms of the last one. */
60 armedAt?: number
61 /** When Run now was last pressed and ignored; the 600 ms gap runs from it too. */
62 quietFrom?: number
63 /** The argv the arming press resolved, shown before Run now and checked again at it. */
64 armedArgv?: string[]
65 /** Why the arming press found no shell to run; Run now then records it and runs nothing. */
66 armedError?: string
67 run?: InboxRun
68 /** When the entry resolved without a Run: the same part later ran fine, or the filer said it is done. */
69 resolvedAt?: number
70 resolvedBy?: string
71}
72
73/**
74 * One entry read from another session's file in the shared folder. Every
75 * field is untrusted text: it is shown and copied, never run. Its place is
76 * launched only as Go to session, at a folder rebuilt from this machine's own
77 * environment and only while the process list shows that instance running.
78 */
79export type InboxRemoteEntry = {
80 key: string
81 sessionLabel: string
82 kind: 'question' | 'refused' | 'signal'
83 createdAt: number
84 questions: InboxQuestion[]
85 shell?: string
86 command?: string
87 withheld?: string
88 cwd?: string
89 refusal?: string
90 detail?: string
91 ask?: string
92 viaOutput?: boolean
93 title?: string
94 why?: string
95 recommendedAction?: string
96 needs?: InboxNeed
97 reviewOutcome?: string
98 confidence?: string
99 /**
100 * Where the app shows the asking session: its desktop instance's data
101 * folder and its app session id, as that session wrote them.
102 */
103 place?: InboxPlace
104 /** When the asking session last wrote its file; Go to session shows while it is recent. */
105 placeAt?: number
106}
107
108/** A desktop app instance's data folder and one of its session ids. */
109export type InboxPlace = { instance: string; id: string }
110
111/** A remote entry the owner dismissed: its key and when. */
112export type InboxDismissal = { key: string; at: number }
113
114declare module 'claude-code' {
115 interface PluginState {
116 'korus-inbox': {
117 own: InboxEntry[]
118 /**
119 * The newest remote entries waiting, at most the 40 the pane draws, and
120 * the count of every remote entry waiting, held or not.
121 */
122 remoteHeld: { entries: InboxRemoteEntry[]; total: number }
123 dismissed: InboxDismissal[]
124 folderNote: string | null
125 /** Which Details and the Done section the owner has opened, by key. */
126 expanded: string[]
127 /**
128 * This session's title as the app's session list shows it, or null until
129 * the engine has handed one over. Held here so a reload keeps it.
130 */
131 title: string | null
132 }
133 }
134}
135