SLOPSHOPPER

korus-inbox

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…

newpanebandguardcommandtoast
★ 1v0.2.4MITupdated 2026-10-07MEFORORG/korus/plugins/korus-inbox
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · korus-inbox
│ ┃ Owner inbox ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ 0 waiting on the owner │ korus-inbox │ │ ┃ Nothing waits on the owner. ⏺ Read(src/auth.ts) │ Owner inbox: the owner_action tools did │ │ ⎿ Read 6 lines │ not register: undefined is not an object │ │ ⏺ Update(src/auth.ts) │ (evaluating '(await $.tool.register({ │ │ ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /inbox │ ⎿ korus-inbox: Owner inbox opened: 0 waiting. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Owner inbox
0 waiting on the owner Nothing waits on the owner.
README

KORUS

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.

Status: early, and the spec is being written now

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.

Start here: the constitution

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:

INo session is the only reader of its own work
IIPublish readings, not conclusions
IIIA gate that cannot check identity must make refusal legible
IVEvery claim names the condition it did not vary
VNo rule may manufacture its own evidence
VIA number without its instrument is not a measurement
VIIWaiting is a design cost and it is measured
VIIIThe account roster is assigned by the Owner, and no design may infer it
IXBuilt for Claude Code, and no design may require a particular surface
XA seat that cannot be measured cannot be steered
XIA seat's job is to write something down
XIIThe shared write surface is the boundary that binds, not the account
XIIIWork 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.

What problem it solves

Run more than one AI session on one repository and four things go wrong:

  1. They collide. Two sessions in one checkout overwrite each other's work.
  2. They lose work. A session ends and its context, findings and half-finished branches go with it.
  3. They agree wrongly. Two sessions that share a hidden condition reach the same wrong answer and their agreement reads as confirmation.
  4. They cannot be told apart. A stalled session and a working one look identical from outside.

KORUS is the set of roles, gates and instruments that address these.

Two design facts that came from measuring rather than guessing

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:

  • A banner asking sessions to use worktrees does not work. 6,075 of those calls (44%) landed in the primary's own tree. If a convention matters, enforce it with a hook. A reminder produces no evidence either way.
  • Gate on the write's target path, never the session's working directory. Another 4,010 of them (29%) landed inside a sibling worktree by absolute path, which is already correct behaviour that a directory-keyed gate would have denied every one of.

This page is the record for both figures. Neither has been re-measured, and nothing in this repository can recompute them.

The shape

Work is divided among seats, each a session with one job:

SeatWhat it doesLifetime
ManagerPlans, runs workers, holds the owner's attentionlong-lived
BuilderTakes one brief, does the work, opens a pull requestone turn
StewardWrites files other seats readcron, no model calls
LanderDecides merge orderlong-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.

Repository layout

.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

Related

  • claude-multisession -- the earlier public home for KORUS docs and scripts. Its site deploy was disabled on 2026-09-08, and korus publishes the site now. The findings moved here too, so the clause saying they live there is retired.

Licence

See LICENSE.

Source 2 files
hooks/register.tsx 2396 lines
1import { 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 one
types/index.d.ts 135 lines
1// 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