Pane of this session's agents plus awareness of parallel Claude sessions on the same git project, task-level path ownership (/task), file claims, and /tell to…

Preflight coordination for developers running several AI coding sessions (Claude Code worktrees first) on one Git repository: declare which task owns which paths, get warned before two sessions collide, and hand off work with evidence of what was actually tested.
Status: G1 desk research done (docs/RESEARCH_G1.md). G2 prototype starts from the existing session-agents mod. See docs/MASTER_PLAN.md for the plan and docs/MOD_REVIEW.md for the review.
Risky overlap caught before merge. Two tasks claim the same path, the same file is changed in two worktrees, and two migrations under different names are flagged as one hotspot. Every finding ends with a fix; --strict exits 3.

Clean repo. Two well-separated tasks, each mapped to its worktree, and a suggested merge order.

Evidence-based handoff. The report says not verified until guardian ran the tests itself, then records the exit code and a fingerprint of the tree they ran on.

Reproduce them with bash demo/scenarios.sh (see docs/DEMO.md).
| Path | What it is |
|---|---|
plugins/session-agents/ | Working Claude Code mod v0.17.0 (macOS, Linux, Windows): /sessions control pane, /task ownership shared across worktrees with an ask-the-user hold on violations, live session list, file claims, edit pause, peer messaging, git/worktree view. Docs in docs/SESSION_AGENTS_MOD.md. |
.claude-plugin/marketplace.json | Makes this repository a plugin marketplace. |
LICENSE | PolyForm Shield 1.0.0: free to use, including at work, and to modify; not permitted to use it to provide a competing product. Paid features live in a separate private repository. |
scripts/git-parallel-session-check.sh | UserPromptSubmit hook that warns when another session shares this checkout. |
src/ | guardian CLI skeleton (TypeScript). Commands are stubs until G2. |
docs/ | Master plan, G1 research, mod review, execution tracker, launch playbook. |
/plugin install session-agents --marketplace d10or/workspace-guardian
Then start a new session and type /sessions. Declare ownership with /task add AUTH 'src/auth/**' --branch feature/auth. Replace d10or/workspace-guardian once this repository is pushed to GitHub.
mkdir -p ~/.claude/hooks
cp scripts/git-parallel-session-check.sh ~/.claude/hooks/
chmod +x ~/.claude/hooks/git-parallel-session-check.sh
Then add it to UserPromptSubmit in ~/.claude/settings.json (merge, do not replace):
{ "hooks": { "UserPromptSubmit": [ { "hooks": [
{ "type": "command", "command": "bash \"$HOME/.claude/hooks/git-parallel-session-check.sh\"" }
] } ] } }
guardian CLI (v0.1)Reads and writes the same .guardian/tasks.json as the mod, so a task declared with /task add in Claude Code is what guardian check reports on, and vice versa.
| Command | Effect | ||
|---|---|---|---|
guardian inspect [--json] | Branch, head, base, every worktree with uncommitted and committed-since-base counts, locks and in-flight git operations, and which worktree carries each task. Read-only. | ||
guardian task add --id AUTH --paths 'src/auth/**' --branch feature/auth [--note …] [--yes] | Declare ownership. Previews, asks for confirmation (or --yes), then writes the task file atomically. Warns on declared overlaps. | ||
guardian task done --id AUTH / guardian task list [--all] | Release a task's paths / show tasks and declared overlaps. | ||
guardian check [--json] [--strict] [--base <ref>] | Declared overlaps, the same path changed in two worktrees, paths owned by another task, paths outside a task's claim, hotspot categories (lockfiles, migrations, schemas, generated code, routes, config) touched by several worktrees, worktrees behind base, in-flight git operations, unresolved conflicts, stale tasks. Each finding carries a fix. --strict exits 3 when risks exist. Advisory: it flags risk, never certifies a merge. | ||
| `guardian license add <key> \ | show \ | remove` | Optional Pro key, verified offline with an embedded public key. Without one, everything above works unchanged. |
| `guardian pro install <tarball> \ | status \ | remove` | Install the Pro package delivered by the checkout. Offline. |
guardian usage [--json] [--reset] | Local counts only (which command, which day), nothing sent; paste into feedback if you want to. GUARDIAN_NO_USAGE=1 disables. | ||
guardian handoff --task AUTH [--run 'npm test'] [--evidence file] [--out report.md] [--json] | Markdown or JSON: task, branch and head, ahead/behind base, commits since base, changed paths with "outside claim" marks, test evidence, open risks, next steps. Tests are verified only when guardian ran them itself (--run) and records the exit code plus a fingerprint of the tree they ran on. Supplied output is marked supplied, not verified. Otherwise not verified. |
Guardian never runs checkout, rebase, merge, push, reset, or delete on your behalf. Git is called with argument arrays, never a shell string. JSON output carries schemaVersion: 1.
Pro (separate, paid, not in this repository) adds to check: a scored merge plan with the exact rebase steps and the files to resolve, per-repo policies in .guardian/policy.json (which finding codes fail, which are ignored or downgraded), and framework hotspot packs (Next.js, Django, Rails, monorepos, Terraform) plus custom rules in .guardian/hotspots.json. The free core prints a one-line hint where Pro would add something, and nothing else changes.
Mod (needs Claude Code 2.1.287+):
claude plugin validate plugins/session-agents
claude plugin test plugins/session-agents
bash tests/hook.test.sh
CLI (Node.js 22+):
npm install
npm test # unit tests + an end-to-end suite on a throwaway repo with two worktrees
npm run dev -- check --json
npm run build && node dist/index.js --help
.claude/ project settings (SessionStart hook) and skills: /checkpoint, /resume, /gate
.claude-plugin/ marketplace manifest
plugins/ Claude Code plugins and mods
scripts/ shell hooks
docs/ plan, research, checkpoint (living state), progress log, tracker, audits, guides
src/ CLI source (TypeScript, ESM)
tests/ node:test suites and fixtures
.guardian/ local task state, git-ignoredhooks/register.tsx 2167 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { findViolation, globMatches, parseTaskAdd, parseTasks, serializeTasks, taskFilePath, tasksOverlap } from './tasks.ts'
5import type { AgentRow, ClaimRow, TaskRow, GitInfo, LimitRow, HistoryRow, MessageRow, Notify, NoteRow, PeerRow, PinRow, SelfRow, SubRow, UsageInfo, UsageRow, Tab, UiState, WorktreeRow } from '../types'
6
7const PANE = 'session-agents'
8// Shown in the pane footer so you can tell which version a session loaded. Keep equal to plugin.json.
9const VERSION = '0.18.1'
10// Everything under the user's Claude config folder is resolved at run time (ensurePaths), so the
11// plugin carries no machine- or user-specific path. Data lives in <config>/session-agents/.
12const P = {
13 home: '', cfg: '', sessions: '', projects: '', data: '', bus: '',
14 notes: '', history: '', pins: '', msgs: '', prefs: '', claims: '', claimsDir: '', msgsDir: '', usageDir: '', limits: '',
15}
16const MAX_MSGS = 200
17const MAX_HOPS = 4
18const CLAIM_DEFAULT_MIN = 60
19const CLAIM_MAX_MIN = 480
20const TOOL_CLAIM = 'mcp__session-agents__claim_files'
21const TOOL_RELEASE = 'mcp__session-agents__release_claims'
22const SUB_RUNNING_MS = 60 * 1000
23const SUB_RECENT_MS = 2 * 60 * 60 * 1000
24const PRECHECK_WINDOW_MS = 10 * 60 * 1000
25const WARN_MEMORY_MS = 5 * 60 * 1000
26const HOP_WINDOW_MS = 10 * 60 * 1000
27const RATE_MAX = 6
28const RATE_WINDOW_MS = 10 * 60 * 1000
29const MARKER = /\[session-agents msg=(\w+) hop=(\d+)\/(\d+) from="([^"]*)" fromId=([0-9a-f-]+)[^\]]*\]\s*/
30const TOOL_SEND = 'mcp__session-agents__message_peer'
31const TOOL_LIST = 'mcp__session-agents__list_peers'
32const TOOL_TASKS = 'mcp__session-agents__list_tasks'
33const MAX_HISTORY = 300
34const BEAT_MS = 5000
35const MAX_FILES = 25
36// Glyphs with one meaning each, shared by the pane, the band and the toasts.
37const G = { busy: '●', idle: '○', pin: '★', task: '◆', claim: '🔒', risk: '⚠', held: '⏸', ok: '✓' } as const
38
39const agents = atom({ plugin: 'session-agents', key: 'agents' } as const, [])
40const peers = atom({ plugin: 'session-agents', key: 'peers' } as const, [])
41const me = atom({ plugin: 'session-agents', key: 'me' } as const, null)
42const usageAtom = atom({ plugin: 'session-agents', key: 'usage' } as const, null)
43const claimsAtom = atom({ plugin: 'session-agents', key: 'claims' } as const, [])
44const tasksAtom = atom({ plugin: 'session-agents', key: 'tasks' } as const, [])
45const msgsAtom = atom({ plugin: 'session-agents', key: 'messages' } as const, [])
46const pinsAtom = atom({ plugin: 'session-agents', key: 'pins' } as const, [])
47const historyAtom = atom({ plugin: 'session-agents', key: 'history' } as const, [])
48const notesAtom = atom({ plugin: 'session-agents', key: 'notes' } as const, [])
49const gitAtom = atom({ plugin: 'session-agents', key: 'git' } as const, null)
50const DEFAULT_UI: UiState = {
51 tab: 'overview',
52 busyOnly: false,
53 hideBg: false,
54 flash: '',
55 detail: [],
56 notify: { start: true, done: true, wait: true },
57 precheck: true,
58 sharePrompt: false,
59 theme: 'auto',
60 jumpAuto: true,
61 expanded: [],
62}
63const uiAtom = atom({ plugin: 'session-agents', key: 'ui' } as const, DEFAULT_UI)
64
65// State outlives a hot reload, so a value saved by an older version may lack newer fields.
66const fixUi = (u: Partial<UiState> | null | undefined): UiState => ({
67 ...DEFAULT_UI,
68 ...(u ?? {}),
69 notify: { ...DEFAULT_UI.notify, ...(u?.notify ?? {}) },
70 detail: u?.detail ?? [],
71 expanded: Array.isArray(u?.expanded) ? u!.expanded : [],
72})
73
74const ACTIONS_UNIX: { key: string; label: string; argv: string[]; where: 'here' | 'any' }[] = [
75 { key: 'log', label: 'Recent commits', argv: ['git', 'log', '--oneline', '--decorate', '-n', '12'], where: 'here' },
76 { key: 'branches', label: 'Recent branches', argv: ['git', 'for-each-ref', '--sort=-committerdate', '--count=15', '--exclude=refs/remotes/origin/HEAD', '--format=%(committerdate:relative) %(refname:short)', 'refs/heads', 'refs/remotes'], where: 'here' },
77 { key: 'diff', label: 'Diff summary', argv: ['git', 'diff', '--stat'], where: 'here' },
78 { key: 'stash', label: 'Stashes', argv: ['git', 'stash', 'list'], where: 'here' },
79 { key: 'prune', label: 'Stale worktrees (dry run)', argv: ['git', 'worktree', 'prune', '--dry-run', '-v'], where: 'here' },
80 { key: 'disk', label: 'Disk free', argv: ['df', '-h', '/'], where: 'any' },
81 { key: 'procs', label: 'Claude processes', argv: ['pgrep', '-fl', 'claude'], where: 'any' },
82 { key: 'prs', label: 'Open PRs (gh)', argv: ['gh', 'pr', 'list', '--limit', '10'], where: 'here' },
83]
84const ACTIONS_WIN = ACTIONS_UNIX.map(a =>
85 a.key === 'disk' ? { ...a, argv: ['powershell', '-NoProfile', '-Command', 'Get-PSDrive -PSProvider FileSystem | Select-Object Name,Used,Free | Format-Table -AutoSize'] }
86 : a.key === 'procs' ? { ...a, argv: ['tasklist', '/FI', 'IMAGENAME eq claude.exe', '/FO', 'TABLE', '/NH'] }
87 : a,
88)
89const ACTIONS = () => (S.isWin ? ACTIONS_WIN : ACTIONS_UNIX)
90
91type Beat = { sessionId: string; files: string[]; editAt?: Record<string, number>; prompt: string; updatedAt: number }
92
93const S = {
94 sessionId: '',
95 root: '',
96 project: '',
97 branch: '',
98 prompt: '',
99 note: 'starting',
100 noteKey: '',
101 overlapSeen: '',
102 isTimerOn: false,
103 files: [] as string[],
104 projectOf: new Map<string, string>(),
105 lastStatus: new Map<string, string>(),
106 activityAt: new Map<string, number>(),
107 activityOf: new Map<string, { task: string; activity: string }>(),
108 busySince: new Map<string, number>(),
109 branchOf: new Map<string, { at: number; branch: string }>(),
110 seenClaims: new Set<string>(),
111 taskAllowed: new Set<string>(),
112 tasksSeen: '',
113 myCwd: '',
114 usageBusy: false,
115 limitsSeen: false,
116 usageAttemptAt: 0,
117 usageReady: false,
118 usageCache: new Map<string, { off: number; inp: number; out: number; cc: number; cr: number; turns: number; last: number; model: string }>(),
119 ctxWarned: new Map<string, number>(),
120 limitWarned: new Set<string>(),
121 seenMsgs: new Set<string>(),
122 msgsReady: false,
123 claimsReady: false,
124 myName: '',
125 hopCtx: null as { hop: number; at: number } | null,
126 sent: [] as number[],
127 editAt: new Map<string, number>(),
128 subCache: new Map<string, { at: number; rows: SubRow[] }>(),
129 subMeta: new Map<string, { label: string; type: string }>(),
130 warned: new Map<string, number>(),
131 tickNo: 0,
132 baseRef: '',
133 isMac: false,
134 isWin: false,
135 py: '' as string,
136 pyFailed: false,
137}
138
139const PALETTE: Record<string, string> = {
140 me: 'cyan', usage: 'blue', plan: 'magenta', ctx: 'cyan', claim: 'yellow', pin: 'magenta', busy: 'green', sub: 'magenta', wt: 'blue', mg: 'cyan', open: 'yellow', done: 'gray', flt: 'gray',
141}
142const AGENT_COLOR: Record<string, string> = {
143 running: 'green', completed: 'gray', failed: 'red', killed: 'red', idle: 'yellow', waiting: 'yellow', pending: 'blue',
144}
145
146const short = (id: string) => id.slice(0, 8)
147const hhmm = (at: number) => {
148 const d = new Date(at)
149
150 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
151}
152const home = (path: string) => (P.home !== '' ? path.replace(P.home, '~') : path)
153const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
154
155const ago = (ms: number) => {
156 const s = Math.max(0, Math.round(ms / 1000))
157 return s < 60 ? `${s}s` : s < 3600 ? `${Math.round(s / 60)}m` : s < 86400 ? `${Math.round(s / 3600)}h` : `${Math.round(s / 86400)}d`
158}
159
160// The pane's own background is dark whatever the terminal theme, while plain text takes the
161// terminal's foreground, so on a light terminal text vanishes. Every color is therefore set
162// explicitly after the tree is built: a white page with dark ink in light mode, and the original
163// look (light ink, no background override) in dark mode.
164type Palette = { fg: string; bg: string; map: Record<string, string> }
165const LIGHT: Palette = {
166 fg: '#1f2937',
167 bg: '#ffffff',
168 map: {
169 cyan: '#0e7490', green: '#15803d', yellow: '#a16207', red: '#b91c1c',
170 magenta: '#a21caf', blue: '#1d4ed8', gray: '#6b7280', white: '#111827',
171 },
172}
173const DARK: Palette = { fg: '#e5e7eb', bg: '', map: {} }
174
175function paint(node: any, pal: Palette): any {
176 if (Array.isArray(node)) return node.map(child => paint(child, pal))
177 if (node === null || typeof node !== 'object' || typeof node.type !== 'string') return node
178
179 const props: Record<string, unknown> = { ...(node.props ?? {}) }
180 const pick = (c: unknown) => (typeof c === 'string' ? pal.map[c] ?? c : c)
181 if (node.type === 'Text') {
182 props.color = pick(props.color) ?? pal.fg
183 if (pal.bg !== '' && props.backgroundColor === undefined) props.backgroundColor = pal.bg
184 } else if (node.type === 'Box') {
185 if (props.borderColor !== undefined) props.borderColor = pick(props.borderColor)
186 if (pal.bg !== '' && props.backgroundColor === undefined) props.backgroundColor = pal.bg
187 }
188
189 return node.children === undefined ? { ...node, props } : { ...node, props, children: paint(node.children, pal) }
190}
191
192async function ensurePaths($: any) {
193 if (P.home !== '') return
194 // Unix first; where `sh` is missing (Windows) ask PowerShell for the same three values.
195 let r = await $.process
196 .run(['sh', '-c', 'printf "%s\n%s\n%s" "$HOME" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}" "$(uname)"'])
197 .catch(() => undefined)
198 if (!r || r.exitCode !== 0) {
199 r = await $.process
200 .run(['powershell', '-NoProfile', '-Command', '$h=$env:USERPROFILE; $c=$env:CLAUDE_CONFIG_DIR; if(-not $c){$c="$h\\.claude"}; Write-Output $h; Write-Output $c; Write-Output Windows_NT'])
201 .catch(() => undefined)
202 }
203 const [home = '', cfg = '', os = ''] = r && r.exitCode === 0 ? (r.stdout as string).replace(/\r/g, '').split('\n').map((x: string) => x.replace(/\\/g, '/')) : []
204 if (home === '') return
205 P.home = home
206 P.cfg = cfg || `${home}/.claude`
207 P.sessions = `${P.cfg}/sessions`
208 P.projects = `${P.cfg}/projects`
209 P.data = `${P.cfg}/session-agents`
210 P.bus = `${P.data}/bus`
211 for (const name of ['notes', 'history', 'pins', 'prefs', 'claims'] as const) P[name] = `${P.data}/${name}.json`
212 P.msgs = `${P.data}/messages.json`
213 P.claimsDir = `${P.data}/claims`
214 P.msgsDir = `${P.data}/messages`
215 P.usageDir = `${P.cfg}/guardian/usage`
216 P.limits = `${P.data}/limits.json`
217 S.isMac = os.trim() === 'Darwin'
218 S.isWin = os.trim() === 'Windows_NT'
219 if (S.isWin) {
220 await $.process
221 .run(['powershell', '-NoProfile', '-Command', `New-Item -ItemType Directory -Force -Path '${P.data}','${P.data}/claims','${P.data}/messages','${P.cfg}/guardian/usage' | Out-Null`])
222 .catch(() => undefined)
223 return
224 }
225 // One-time carry-over from the folder earlier versions kept their data in.
226 await $.process
227 .run(['sh', '-c', 'mkdir -p "$1" "$1/claims" "$1/messages" "$3"; for f in notes history pins messages prefs claims; do [ -f "$2/$f.json" ] && [ ! -f "$1/$f.json" ] && cp "$2/$f.json" "$1/$f.json"; done; true', 'sh', P.data, `${P.cfg}/lab-notes`, `${P.cfg}/guardian/usage`])
228 .catch(() => undefined)
229}
230
231async function git($: any, cwd: string, ...args: string[]) {
232 const r = await $.process.run(['git', ...args], { cwd }).catch(() => undefined)
233 return r && r.exitCode === 0 ? (r.stdout as string).trim() : ''
234}
235
236async function init($: any) {
237 await ensurePaths($)
238 if (S.sessionId !== '') return
239 S.sessionId = await $.session.id()
240 S.root = await $.session.root()
241 S.branch = (await git($, S.root, 'rev-parse', '--abbrev-ref', 'HEAD')) || '?'
242 S.project = (await git($, S.root, 'rev-parse', '--path-format=absolute', '--git-common-dir')) || S.root
243 S.projectOf.set(S.root, S.project)
244 S.baseRef = await detectBase($)
245}
246
247// The branch "ahead / behind" and "merged" are measured against: the remote's default branch.
248async function detectBase($: any): Promise<string> {
249 const head = await git($, S.root, 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD')
250 if (head !== '') return head
251 for (const ref of ['origin/main', 'origin/master']) {
252 if ((await git($, S.root, 'rev-parse', '--verify', '--quiet', ref)) !== '') return ref
253 }
254
255 return ''
256}
257
258async function gatherGit($: any): Promise<GitInfo> {
259 const dirtyOf = async (cwd: string) => {
260 const out = await git($, cwd, 'status', '--porcelain')
261 return out === '' ? 0 : out.split('\n').length
262 }
263 const base = S.baseRef
264 const counts = base !== '' ? await git($, S.root, 'rev-list', '--left-right', '--count', `${base}...HEAD`) : ''
265 const [behind = 0, ahead = 0] = counts === '' ? [] : counts.split(/\s+/).map(Number)
266 const raw = await git($, S.root, 'worktree', 'list', '--porcelain')
267 const worktrees: WorktreeRow[] = []
268 for (const block of raw.split('\n\n')) {
269 const path = /^worktree (.+)$/m.exec(block)?.[1]
270 if (path === undefined) continue
271 const ref = /^branch refs\/heads\/(.+)$/m.exec(block)?.[1]
272 worktrees.push({
273 path,
274 branch: ref ?? (/^detached$/m.test(block) ? '(detached)' : '(bare)'),
275 dirty: await dirtyOf(path),
276 isHere: path === S.root,
277 })
278 }
279 const merged = (base === '' ? '' : await git($, S.root, 'branch', '--merged', base, '--format=%(refname:short)'))
280 .split('\n')
281 .filter(b => b !== '' && b !== 'main' && b !== 'master' && b !== S.branch)
282
283 return { branch: S.branch, dirty: await dirtyOf(S.root), ahead, behind, base, worktrees, merged }
284}
285
286function lastText(content: unknown): string {
287 if (typeof content === 'string') return content
288 if (!Array.isArray(content)) return ''
289 const block = content.find((b: any) => b && b.type === 'text' && typeof b.text === 'string')
290
291 return block ? block.text : ''
292}
293
294async function readActivity($: any, r: { sessionId: string; cwd: string }, now: number) {
295 const cached = S.activityOf.get(r.sessionId)
296 if (cached && now - (S.activityAt.get(r.sessionId) ?? 0) < 15000) return cached
297
298 let task = ''
299 let activity = ''
300 const file = `${P.projects}/${r.cwd.replace(/[^a-zA-Z0-9]/g, '-')}/${r.sessionId}.jsonl`
301 const out = await $.process
302 .run(S.isWin ? ['powershell', '-NoProfile', '-Command', `Get-Content -LiteralPath '${file}' -Tail 80 -Encoding UTF8`] : ['tail', '-c', '120000', file])
303 .catch(() => undefined)
304 const lines = out && out.exitCode === 0 ? (out.stdout as string).replace(/\r/g, '').split('\n').reverse() : []
305 for (const line of lines) {
306 if (task !== '' && activity !== '') break
307 let row: any
308 try {
309 row = JSON.parse(line)
310 } catch {
311 continue
312 }
313 const content = row?.message?.content
314 if (activity === '' && row?.type === 'assistant' && Array.isArray(content)) {
315 const tool = [...content].reverse().find((b: any) => b?.type === 'tool_use')
316 if (tool) {
317 const input = tool.input ?? {}
318 const arg = input.command ?? input.file_path ?? input.pattern ?? input.description ?? ''
319 activity = `${tool.name} ${String(arg).replace(/\s+/g, ' ').slice(0, 70)}`.trim()
320 } else {
321 activity = lastText(content).replace(/\s+/g, ' ').slice(0, 80)
322 }
323 }
324 if (task === '' && row?.type === 'user' && row?.isMeta !== true) {
325 const text = lastText(content).replace(/\s+/g, ' ').trim()
326 if (text !== '' && !text.startsWith('<') && !text.startsWith('[')) task = text.slice(0, 90)
327 }
328 }
329 const found = { task, activity }
330 S.activityOf.set(r.sessionId, found)
331 S.activityAt.set(r.sessionId, now)
332
333 return found
334}
335
336// Another session's subagents are not in $.agent.list() (that is this session only), but each
337// one keeps a transcript under <session>/subagents/. One that wrote in the last minute is active.
338async function readSubagents($: any, r: { sessionId: string; cwd: string }, now: number): Promise<SubRow[]> {
339 const hit = S.subCache.get(r.sessionId)
340 if (hit !== undefined && now - hit.at < 8000) return hit.rows
341
342 const dir = `${P.projects}/${r.cwd.replace(/[^a-zA-Z0-9]/g, '-')}/${r.sessionId}/subagents`
343 let entries: { name: string; mtimeMs: number }[] = []
344 try {
345 entries = await $.fs.list(dir)
346 } catch {
347 entries = []
348 }
349 const rows: SubRow[] = []
350 const files = entries
351 .filter(f => f.name.endsWith('.jsonl') && (now - f.mtimeMs < SUB_RECENT_MS))
352 .sort((a, b) => b.mtimeMs - a.mtimeMs)
353 .slice(0, 6)
354 for (const file of files) {
355 const id = file.name.replace(/^agent-/, '').replace(/\.jsonl$/, '')
356 let meta = S.subMeta.get(id)
357 if (meta === undefined) {
358 try {
359 const m = JSON.parse(await $.fs.read(`${dir}/agent-${id}.meta.json`))
360 meta = { label: String(m.description ?? m.name ?? m.agentType ?? id).replace(/\s+/g, ' ').slice(0, 80), type: String(m.agentType ?? '') }
361 } catch {
362 meta = { label: id, type: '' }
363 }
364 S.subMeta.set(id, meta)
365 }
366 rows.push({ id, label: meta.label, type: meta.type, isRunning: now - file.mtimeMs < SUB_RUNNING_MS, at: Math.floor(file.mtimeMs / 1000) * 1000 })
367 }
368 S.subCache.set(r.sessionId, { at: now, rows })
369
370 return rows
371}
372
373async function tick($: any) {
374 try {
375 await init($)
376 const now: number = await $.clock.now()
377 // The prompt's first line is shared with other sessions only when the user opted in: prompts can hold secrets.
378 const sharePrompt = fixUi(await read($, uiAtom)).sharePrompt
379 const beat: Beat = { sessionId: S.sessionId, files: S.files, editAt: Object.fromEntries(S.editAt), prompt: sharePrompt ? S.prompt : '', updatedAt: now }
380 await $.fs.write(`${P.bus}/${S.sessionId}.json`, JSON.stringify(beat))
381
382 const regs: any[] = []
383 for (const entry of await $.fs.list(P.sessions)) {
384 if (!entry.name.endsWith('.json')) continue
385 try {
386 regs.push(JSON.parse(await $.fs.read(`${P.sessions}/${entry.name}`)))
387 } catch {
388 // being rewritten: next tick
389 }
390 }
391 for (const r of regs.filter(x => x.spare !== true)) await observe($, r, now, r.sessionId === S.sessionId)
392 const mine = regs.find(r => r.sessionId === S.sessionId)
393 if (typeof mine?.name === 'string') S.myName = mine.name
394 if (typeof mine?.cwd === 'string') S.myCwd = mine.cwd
395 const candidates = regs.filter(r => r.sessionId !== S.sessionId && r.spare !== true && Number.isInteger(r.pid) && r.pid > 0)
396 const ttyOf = new Map<number, string>()
397 if (candidates.length && S.isWin) {
398 // tasklist CSV: "Image Name","PID",... — one call for every process, then keep the candidates.
399 const tl = await $.process.run(['tasklist', '/FO', 'CSV', '/NH']).catch(() => undefined)
400 const wanted = new Set(candidates.map(r => r.pid))
401 for (const line of ((tl?.stdout as string) ?? '').split('\n')) {
402 const pid = Number(line.split('","')[1])
403 if (wanted.has(pid)) ttyOf.set(pid, '')
404 }
405 } else if (candidates.length) {
406 const ps = await $.process.run(['ps', '-o', 'pid=,tty=', '-p', candidates.map(r => r.pid).join(',')]).catch(() => undefined)
407 for (const line of ((ps?.stdout as string) ?? '').split('\n')) {
408 const [pid, tty = ''] = line.trim().split(/\s+/)
409 if (Number(pid)) ttyOf.set(Number(pid), tty === '??' ? '' : tty)
410 }
411 }
412 const alive = new Set<number>(ttyOf.keys())
413
414 const rows: PeerRow[] = []
415 for (const r of candidates.filter(c => alive.has(c.pid))) {
416 let project = S.projectOf.get(r.cwd)
417 if (project === undefined) {
418 project = (await git($, r.cwd, 'rev-parse', '--path-format=absolute', '--git-common-dir')) || r.cwd
419 S.projectOf.set(r.cwd, project)
420 }
421 const cachedBranch = S.branchOf.get(r.cwd)
422 let branch = cachedBranch !== undefined && now - cachedBranch.at < 15000 ? cachedBranch.branch : ''
423 if (branch === '') {
424 branch = (await git($, r.cwd, 'rev-parse', '--abbrev-ref', 'HEAD')) || '?'
425 S.branchOf.set(r.cwd, { at: now, branch })
426 }
427 let files: string[] = []
428 let editAt: Record<string, number> = {}
429 try {
430 const b = JSON.parse(await $.fs.read(`${P.bus}/${r.sessionId}.json`)) as Beat
431 if (now - b.updatedAt < 30000) {
432 files = b.files
433 editAt = b.editAt ?? {}
434 }
435 } catch {
436 // peer does not run this mod
437 }
438 const { task, activity } = await readActivity($, r, now)
439 const subs = await readSubagents($, r, now)
440 const status = r.status ?? '?'
441 rows.push({
442 sessionId: r.sessionId,
443 pid: r.pid,
444 name: r.name ?? short(r.sessionId),
445 kind: r.kind ?? '?',
446 status,
447 cwd: r.cwd,
448 branch,
449 isSameProject: project === S.project,
450 updatedAt: r.updatedAt ?? 0,
451 files,
452 overlap: files.filter(f => S.files.includes(f)),
453 task,
454 activity,
455 tty: ttyOf.get(r.pid) ?? '',
456 jobId: typeof r.jobId === 'string' ? r.jobId : '',
457 editAt,
458 subs,
459 })
460 }
461 rows.sort((a, b) => Number(b.status === 'busy') - Number(a.status === 'busy') || b.updatedAt - a.updatedAt)
462
463 const list = await $.agent.list()
464 const agentRows: AgentRow[] = list.map((a: any) => ({
465 id: a.id,
466 label: a.name ?? a.description ?? a.type ?? a.id,
467 status: a.status,
468 parentId: a.parentAgentId,
469 }))
470
471 if (!S.limitsSeen && now - S.usageAttemptAt > 15000) void usageTick($)
472 if (S.tickNo++ % 3 === 0) {
473 const info = await gatherGit($)
474 await update($, gitAtom, old => (same(old, info) ? old : info))
475 }
476 await syncNotes($)
477 await syncHistory($)
478 await syncPins($)
479 await syncMsgs($)
480 await syncClaims($, now)
481 await syncTasks($)
482 await update($, peers, old => (same(old, rows) ? old : rows))
483 await update($, agents, old => (same(old, agentRows) ? old : agentRows))
484 S.note = `ok ${new Date(now).toISOString().slice(11, 19)}Z`
485 await update($, me, () => ({ sessionId: S.sessionId, root: S.root, branch: S.branch, note: S.note }))
486
487 const overlaps = rows.flatMap(r => r.overlap.map(f => `${short(r.sessionId)}:${f}`)).sort()
488 const overlapKey = overlaps.join('|')
489 if (overlapKey !== '' && overlapKey !== S.overlapSeen) {
490 $.ui.toast(`${G.risk} parallel session edits the same file: ${overlaps[0]}`, { timeoutMs: 8000 })
491 }
492 S.overlapSeen = overlapKey
493
494 const busy = rows.filter(r => r.status === 'busy').length
495 $.ui.status(rows.length ? `${rows.length} sessions${busy ? `, ${busy} busy` : ''}` : undefined)
496 } catch (error) {
497 S.note = `ERROR: ${String(error)}`
498 await update($, me, () => ({ sessionId: S.sessionId, root: S.root, branch: S.branch, note: S.note })).catch(() => undefined)
499 }
500}
501
502async function setFlash($: any, flash: string, detail: string[] = []) {
503 await update($, uiAtom, (u: UiState) => ({ ...u, flash, detail }))
504}
505
506async function copyText($: any, text: string, label: string) {
507 const r = await $.ui.copy({ text, surface: 'terminal' })
508 await setFlash($, r.isCopied ? `Copied ${label}` : `Could not copy ${label}`)
509}
510
511async function fillTell($: any, name: string) {
512 const r = await $.prompt.fill({ text: `/tell ${name.split(/\s+/)[0].toLowerCase()} `, mode: 'replace' })
513 await setFlash($, r.isFilled ? `Type your message for "${name}" and press Enter` : 'Prompt box not available right now')
514}
515
516async function showStatus($: any, path: string) {
517 const out = await git($, path, 'status', '--short', '--branch')
518 await setFlash($, `git status in ${home(path)}`, out.split('\n').slice(0, 14))
519}
520
521async function doFetch($: any) {
522 await setFlash($, 'Fetching origin…')
523 const r = await $.process.run(['git', 'fetch', 'origin'], { cwd: S.root, timeoutMs: 60000 }).catch(() => undefined)
524 await tick($)
525 await setFlash($, r && r.exitCode === 0 ? 'Fetched origin' : 'Fetch failed')
526}
527
528async function toggle($: any, key: 'busyOnly' | 'hideBg') {
529 await update($, uiAtom, (u: UiState) => ({ ...u, [key]: !u[key] }))
530 await savePrefs($)
531}
532
533async function loadNotes($: any): Promise<NoteRow[]> {
534 await ensurePaths($)
535 try {
536 const parsed = JSON.parse(await $.fs.read(P.notes))
537 return Array.isArray(parsed) ? parsed : []
538 } catch {
539 return []
540 }
541}
542
543async function syncNotes($: any) {
544 const list = await loadNotes($)
545 await update($, notesAtom, (old: NoteRow[]) => (same(old, list) ? old : list))
546}
547
548async function saveNotes($: any, change: (list: NoteRow[]) => NoteRow[]) {
549 const next = change(await loadNotes($))
550 await $.fs.write(P.notes, JSON.stringify(next, null, 1))
551 await update($, notesAtom, () => next)
552}
553
554async function addNote($: any, text: string) {
555 const clean = text.replace(/\s+/g, ' ').trim()
556 if (clean === '') return false
557 const now: number = await $.clock.now()
558 await saveNotes($, list => [...list, { id: now.toString(36), text: clean.slice(0, 300), done: false, at: now }])
559
560 return true
561}
562
563async function noteFromPrompt($: any) {
564 const box = await $.prompt.read()
565 if (!(await addNote($, box.text))) return setFlash($, 'Type something in the prompt box first, then press Save')
566 await $.prompt.fill({ text: '', mode: 'replace' })
567 await setFlash($, 'Saved prompt as a note')
568}
569
570async function toggleNote($: any, id: string) {
571 await saveNotes($, list => list.map(n => (n.id === id ? { ...n, done: !n.done } : n)))
572}
573
574async function deleteNote($: any, id: string) {
575 await saveNotes($, list => list.filter(n => n.id !== id))
576}
577
578async function clearDone($: any) {
579 await saveNotes($, list => list.filter(n => !n.done))
580}
581
582async function loadHistory($: any): Promise<HistoryRow[]> {
583 await ensurePaths($)
584 try {
585 const parsed = JSON.parse(await $.fs.read(P.history))
586 return Array.isArray(parsed) ? parsed : []
587 } catch {
588 return []
589 }
590}
591
592async function syncHistory($: any) {
593 const list = await loadHistory($)
594 await update($, historyAtom, (old: HistoryRow[]) => (same(old, list) ? old : list))
595}
596
597async function recordFinish($: any, row: HistoryRow) {
598 const list = await loadHistory($)
599 if (list.some(ev => ev.id === row.id)) return
600 const next = [...list, row].sort((a, b) => a.at - b.at).slice(-MAX_HISTORY)
601 await $.fs.write(P.history, JSON.stringify(next))
602 await update($, historyAtom, () => next)
603}
604
605async function clearHistory($: any) {
606 await $.fs.write(P.history, '[]')
607 await update($, historyAtom, () => [])
608 await setFlash($, 'History cleared')
609}
610
611async function loadPins($: any): Promise<PinRow[]> {
612 await ensurePaths($)
613 try {
614 const parsed = JSON.parse(await $.fs.read(P.pins))
615 return Array.isArray(parsed) ? parsed : []
616 } catch {
617 return []
618 }
619}
620
621async function syncPins($: any) {
622 const list = await loadPins($)
623 await update($, pinsAtom, (old: PinRow[]) => (same(old, list) ? old : list))
624}
625
626async function togglePin($: any, sessionId: string, name: string) {
627 const list = await loadPins($)
628 const next = list.some(pin => pin.sessionId === sessionId)
629 ? list.filter(pin => pin.sessionId !== sessionId)
630 : [...list, { sessionId, name }]
631 await $.fs.write(P.pins, JSON.stringify(next))
632 await update($, pinsAtom, () => next)
633 await setFlash($, next.length > list.length ? `Pinned "${name}"` : `Unpinned "${name}"`)
634}
635
636const myMsgsFile = () => `${P.data}/messages/${S.sessionId}.json`
637
638async function readMsgFile($: any, path: string): Promise<MessageRow[]> {
639 try {
640 const parsed = JSON.parse(await $.fs.read(path))
641 return Array.isArray(parsed) ? parsed.filter(m => m && typeof m.id === 'string') : []
642 } catch {
643 return []
644 }
645}
646
647// Every session appends only to messages/<session>.json; all files plus the legacy messages.json are merged on read.
648async function loadMsgs($: any): Promise<MessageRow[]> {
649 await ensurePaths($)
650 const seen = new Set<string>()
651 const out: MessageRow[] = []
652 const add = (rows: MessageRow[]) => {
653 for (const m of rows) {
654 if (seen.has(m.id)) continue
655 seen.add(m.id)
656 out.push(m)
657 }
658 }
659 let names: string[] = []
660 try {
661 names = (await $.fs.list(P.msgsDir)).map((e: { name: string }) => e.name).filter((n: string) => n.endsWith('.json'))
662 } catch {
663 // no messages dir yet
664 }
665 for (const n of names) add(await readMsgFile($, `${P.msgsDir}/${n}`))
666 add(await readMsgFile($, P.msgs))
667
668 return out.sort((a, b) => a.at - b.at).slice(-MAX_MSGS)
669}
670
671async function syncMsgs($: any) {
672 const list = await loadMsgs($)
673 await update($, msgsAtom, (old: MessageRow[]) => (same(old, list) ? old : list))
674 // Tell the session whose claim or recent edit paused another session.
675 for (const m of list) {
676 if (!S.seenMsgs.has(m.id) && S.msgsReady && m.via === 'pause' && m.toId === S.sessionId && m.fromId !== S.sessionId) {
677 $.ui.toast(`⏸ ${m.fromName} was paused: ${m.text}`.slice(0, 160), { timeoutMs: 10000 })
678 }
679 S.seenMsgs.add(m.id)
680 }
681 S.msgsReady = true
682}
683
684// One line in the Messages tab each time the pre-edit check pauses a session.
685async function recordPause($: any, now: number, rel: string, other: { sessionId: string; name: string }, why: string) {
686 const myName = (S.myName || short(S.sessionId)).replace(/["\n]/g, '').slice(0, 60)
687 await recordMsg($, {
688 id: `p${now.toString(36)}${Math.random().toString(36).slice(2, 5)}`, at: now,
689 fromId: S.sessionId, fromName: myName, toId: other.sessionId, toName: other.name,
690 text: `edit of ${rel} paused (${why})`, hop: 0, via: 'pause', delivered: true,
691 }).catch(() => undefined)
692}
693
694// The sender and the receiver both record a message under the same id, so it appears once.
695async function recordMsg($: any, row: MessageRow) {
696 const mine = await readMsgFile($, myMsgsFile())
697 if (mine.some(m => m.id === row.id)) return
698 await $.fs.write(myMsgsFile(), JSON.stringify([...mine, row].slice(-MAX_MSGS)))
699 await update($, msgsAtom, (old: MessageRow[]) => (old.some(m => m.id === row.id) ? old : [...old, row].sort((a, b) => a.at - b.at).slice(-MAX_MSGS)))
700}
701
702async function clearMsgs($: any) {
703 // Clears this session's own file and the legacy shared file; other sessions' files are theirs.
704 await $.fs.write(myMsgsFile(), '[]')
705 await $.fs.write(P.msgs, '[]').catch(() => undefined)
706 await update($, msgsAtom, (old: MessageRow[]) => old.filter(m => m.fromId !== S.sessionId && m.toId !== S.sessionId))
707 await setFlash($, 'Messages cleared')
708}
709
710async function loadPrefs($: any): Promise<Partial<UiState>> {
711 await ensurePaths($)
712 try {
713 const parsed = JSON.parse(await $.fs.read(P.prefs))
714 return parsed && typeof parsed === 'object' ? parsed : {}
715 } catch {
716 return {}
717 }
718}
719
720async function savePrefs($: any) {
721 const u = fixUi(await read($, uiAtom))
722 await $.fs.write(P.prefs, JSON.stringify({ tab: u.tab, busyOnly: u.busyOnly, hideBg: u.hideBg, notify: u.notify, precheck: u.precheck, sharePrompt: u.sharePrompt, theme: u.theme, jumpAuto: u.jumpAuto }))
723}
724
725async function applyPrefs($: any) {
726 const saved = await loadPrefs($)
727 await update($, uiAtom, (raw: UiState) => {
728 const u = fixUi(raw)
729
730 return fixUi({ ...u, ...saved, notify: { ...u.notify, ...(saved.notify ?? {}) }, flash: u.flash, detail: u.detail })
731 })
732}
733
734function peersText(rows: PeerRow[], claims: ClaimRow[] = []) {
735 if (rows.length === 0) return 'No other live Claude sessions found.'
736 const claimLine = (id: string) => {
737 const mine = claims.filter(c => c.sessionId === id)
738
739 return mine.length ? ` | claims: ${mine.map(c => `${c.paths.join(', ')} until ${hhmm(c.expiresAt)}${c.note ? ` (${c.note})` : ''}`).join('; ')}` : ''
740 }
741
742 return rows
743 .map(r => `- ${r.name} | ${r.status} | branch ${r.branch} | ${r.cwd}${r.files.length ? ` | editing: ${r.files.join(', ')}` : ''}${r.isSameProject ? '' : ' | other project'}${claimLine(r.sessionId)}`)
744 .join('\n')
745}
746
747// One door for /tell and the message_peer tool. The tool path is hop- and rate-limited so two
748// sessions can never answer each other forever; a human's /tell starts a fresh exchange.
749async function sendToPeer($: any, needle: string, text: string, via: 'tell' | 'tool'): Promise<{ ok: boolean; msg: string }> {
750 const now: number = await $.clock.now()
751 const q = needle.trim().toLowerCase()
752 if (q === '' || text.trim() === '') return { ok: false, msg: 'Need both a target session and a message.' }
753 const hits = (await read($, peers)).filter((p: PeerRow) => p.sessionId.startsWith(q) || p.name.toLowerCase().includes(q))
754 if (hits.length !== 1) {
755 return {
756 ok: false,
757 msg: hits.length === 0
758 ? `No parallel session matches "${needle}".`
759 : `"${needle}" matches ${hits.length} sessions (${hits.map((x: PeerRow) => x.name).join(', ')}); be more specific.`,
760 }
761 }
762 const target: PeerRow = hits[0]
763
764 let hop = 1
765 if (via === 'tool') {
766 const ctx = S.hopCtx
767 hop = (ctx !== null && now - ctx.at < HOP_WINDOW_MS ? ctx.hop : 0) + 1
768 if (hop > MAX_HOPS) return { ok: false, msg: `Hop limit (${MAX_HOPS}) reached for this exchange. Stop messaging and tell the user instead.` }
769 S.sent = S.sent.filter(t => now - t < RATE_WINDOW_MS)
770 if (S.sent.length >= RATE_MAX) return { ok: false, msg: `Rate limit: ${RATE_MAX} peer messages per 10 minutes. Tell the user instead.` }
771 S.sent.push(now)
772 }
773
774 const id = now.toString(36) + Math.random().toString(36).slice(2, 5)
775 const myName = (S.myName || short(S.sessionId)).replace(/["\]\n]/g, '').slice(0, 60)
776 const body = `[session-agents msg=${id} hop=${hop}/${MAX_HOPS} from="${myName}" fromId=${S.sessionId} reply-only-if-needed-via-message_peer] ${text.trim().slice(0, 1500)}`
777 const sent = await $.session.send({ to: { sessionId: target.sessionId }, text: body })
778 await recordMsg($, {
779 id, at: now, fromId: S.sessionId, fromName: myName, toId: target.sessionId, toName: target.name,
780 text: text.trim().slice(0, 500), hop, via, delivered: sent.isDelivered === true,
781 })
782
783 return sent.isDelivered
784 ? { ok: true, msg: `Sent to "${target.name}" (hop ${hop}/${MAX_HOPS}).` }
785 : { ok: false, msg: `Not delivered: ${sent.reason}` }
786}
787
788// Each session writes only its own claims file, so no two sessions ever write the same file.
789// The legacy shared claims.json is still read for sessions on older versions, minus our own rows.
790const myClaimsFile = () => `${P.claimsDir}/${S.sessionId}.json`
791
792async function readClaimFile($: any, path: string): Promise<ClaimRow[]> {
793 try {
794 const parsed = JSON.parse(await $.fs.read(path))
795 return Array.isArray(parsed) ? parsed.filter(c => c && typeof c.id === 'string' && typeof c.sessionId === 'string' && Array.isArray(c.paths)) : []
796 } catch {
797 return []
798 }
799}
800
801async function loadClaims($: any): Promise<ClaimRow[]> {
802 await ensurePaths($)
803 const seen = new Set<string>()
804 const out: ClaimRow[] = []
805 const add = (rows: ClaimRow[]) => {
806 for (const c of rows) {
807 if (seen.has(c.id)) continue
808 seen.add(c.id)
809 out.push(c)
810 }
811 }
812 let names: string[] = []
813 try {
814 names = (await $.fs.list(P.claimsDir)).map((e: { name: string }) => e.name).filter((n: string) => n.endsWith('.json'))
815 } catch {
816 // no claims dir yet
817 }
818 for (const n of names) add(await readClaimFile($, `${P.claimsDir}/${n}`))
819 add((await readClaimFile($, P.claims)).filter(c => c.sessionId !== S.sessionId))
820
821 return out
822}
823
824async function syncClaims($: any, now: number) {
825 const list = (await loadClaims($)).filter(c => c.expiresAt > now)
826 await update($, claimsAtom, (old: ClaimRow[]) => (same(old, list) ? old : list))
827 for (const c of list) {
828 if (c.sessionId !== S.sessionId && !S.seenClaims.has(c.id) && S.claimsReady) {
829 $.ui.toast(`${G.claim} ${c.name} claimed ${c.paths.slice(0, 2).join(', ')}${c.paths.length > 2 ? '…' : ''} until ${hhmm(c.expiresAt)}`, { timeoutMs: 10000 })
830 }
831 S.seenClaims.add(c.id)
832 }
833 S.claimsReady = true
834}
835
836async function saveClaims($: any, change: (list: ClaimRow[]) => ClaimRow[]) {
837 const now: number = await $.clock.now()
838 const all = change(await loadClaims($)).filter(c => c.expiresAt > now)
839 await $.fs.write(myClaimsFile(), JSON.stringify(all.filter(c => c.sessionId === S.sessionId)))
840 await update($, claimsAtom, () => all)
841}
842
843// Paths are kept relative to the repo root so a claim matches the same file in every worktree.
844// Windows paths arrive with backslashes and any letter case; compare with forward slashes, case-folded.
845const fwd = (p: string) => p.replace(/\\/g, '/')
846function toRel(absOrRel: string) {
847 const x = fwd(absOrRel)
848 const root = fwd(S.root)
849 if (root === '') return x.replace(/^\/+/, '')
850 const prefix = root.endsWith('/') ? root : root + '/'
851 const hit = S.isWin ? x.toLowerCase().startsWith(prefix.toLowerCase()) : x.startsWith(prefix)
852
853 return hit ? x.slice(prefix.length) : x
854}
855function normPath(raw: string) {
856 return toRel(raw.trim()).replace(/^\.\//, '').replace(/\/+$/, '')
857}
858
859const claimMatches = (pattern: string, rel: string) => globMatches(pattern, rel)
860
861const claimsOverlap = (a: string, b: string) => claimMatches(a, b) || claimMatches(b, a)
862
863async function addClaim($: any, rawPaths: string[], note: string, minutes: number): Promise<{ ok: boolean; msg: string }> {
864 const paths = [...new Set(rawPaths.map(normPath).filter(Boolean))].slice(0, 12)
865 if (paths.length === 0) return { ok: false, msg: 'Give at least one file or folder to claim.' }
866 const now: number = await $.clock.now()
867 const mins = Math.min(CLAIM_MAX_MIN, Math.max(1, Math.round(Number.isFinite(minutes) && minutes > 0 ? minutes : CLAIM_DEFAULT_MIN)))
868 const alive = new Set((await read($, peers)).map((p: PeerRow) => p.sessionId))
869 const clashes = (await loadClaims($)).filter(
870 c => c.sessionId !== S.sessionId && c.expiresAt > now && alive.has(c.sessionId) && c.paths.some(q => paths.some(mine => claimsOverlap(q, mine))),
871 )
872 const myName = (S.myName || short(S.sessionId)).replace(/["\n]/g, '').slice(0, 60)
873 const claim: ClaimRow = {
874 id: now.toString(36) + Math.random().toString(36).slice(2, 5),
875 sessionId: S.sessionId, name: myName, paths, note: note.replace(/\s+/g, ' ').trim().slice(0, 160),
876 at: now, expiresAt: now + mins * 60000,
877 }
878 await saveClaims($, list => [...list, claim])
879 S.seenClaims.add(claim.id)
880 const warn = clashes.length
881 ? ` Warning: overlaps a claim by ${clashes.map(c => `"${c.name}" (${c.paths.join(', ')})`).join('; ')}. Coordinate with them (message_peer) before editing.`
882 : ''
883
884 return { ok: true, msg: `Claimed ${paths.join(', ')} until ${hhmm(claim.expiresAt)} (${mins} min).${warn}` }
885}
886
887async function releaseClaims($: any, rawPaths: string[]): Promise<string> {
888 const paths = rawPaths.map(normPath).filter(Boolean)
889 const before = (await loadClaims($)).filter(c => c.sessionId === S.sessionId).length
890 await saveClaims($, list =>
891 list.filter(c => c.sessionId !== S.sessionId || (paths.length > 0 && !c.paths.some(q => paths.some(x => claimsOverlap(q, x))))),
892 )
893 const after = (await loadClaims($)).filter(c => c.sessionId === S.sessionId).length
894
895 return before === 0 ? 'You have no active claims.' : `Released ${before - after} claim${before - after === 1 ? '' : 's'}.`
896}
897
898// --- Local usage counts (counts only, never a path; shared by the CLI's `guardian usage`) ---
899type UsageEvent = 'session_start' | 'hold_asked' | 'task_add' | 'task_done'
900async function recordUsage($: any, event: UsageEvent) {
901 try {
902 if (P.usageDir === '' || S.sessionId === '') return
903 const file = `${P.usageDir}/mod-${S.sessionId}.json`
904 const day = new Date(await $.clock.now()).toISOString().slice(0, 10)
905 let cur: { v: 1; installId: string; firstSeen: string; days: Record<string, Record<string, number>> } | null = null
906 try {
907 const parsed = JSON.parse(await $.fs.read(file))
908 if (parsed && parsed.v === 1 && parsed.days) cur = parsed
909 } catch {
910 // first write
911 }
912 if (cur === null) cur = { v: 1, installId: Math.random().toString(16).slice(2, 14).padEnd(12, '0'), firstSeen: day, days: {} }
913 const d = (cur.days[day] ??= {})
914 d[event] = (d[event] ?? 0) + 1
915 await $.fs.write(file, JSON.stringify(cur))
916 } catch {
917 // best effort, never in the way
918 }
919}
920
921// --- Task ownership (shared by every worktree of the repo) -----------------
922const tasksFile = () => taskFilePath(fwd(S.project || `${S.root}/.git`))
923
924async function loadTasks($: any): Promise<TaskRow[]> {
925 try {
926 return parseTasks(await $.fs.read(tasksFile()))
927 } catch {
928 return []
929 }
930}
931
932async function saveTasks($: any, change: (list: TaskRow[]) => TaskRow[]) {
933 const next = change(await loadTasks($))
934 await $.fs.write(tasksFile(), serializeTasks(next))
935 await update($, tasksAtom, () => next)
936
937 return next
938}
939
940async function syncTasks($: any) {
941 const list = await loadTasks($)
942 await update($, tasksAtom, (old: TaskRow[]) => (same(old, list) ? old : list))
943 const key = list.filter(t => t.status === 'active').map(t => `${t.id}@${t.branch}`).join('|')
944 if (S.tasksSeen !== '' && key !== S.tasksSeen) {
945 const added = list.filter(t => t.status === 'active' && !S.tasksSeen.includes(`${t.id}@${t.branch}`))
946 if (added.length) $.ui.toast(`${G.task} task ${added[0].id} now owns ${added[0].paths.slice(0, 2).join(', ')} on ${added[0].branch || 'any branch'}`, { timeoutMs: 10000 })
947 }
948 S.tasksSeen = key
949}
950
951async function addTask($: any, args: string): Promise<string> {
952 const r = parseTaskAdd(args, S.branch === '?' ? '' : S.branch)
953 if (!r.ok) return r.msg
954 const now: number = await $.clock.now()
955 const myName = (S.myName || short(S.sessionId)).replace(/["\n]/g, '').slice(0, 60)
956 const existing = await loadTasks($)
957 if (existing.some(t => t.id === r.id && t.status === 'active')) return `Task ${r.id} already exists. /task done ${r.id} first, or pick another id.`
958 const task: TaskRow = { id: r.id, paths: r.paths, branch: r.branch, owner: myName, note: r.note, createdAt: now, status: 'active' }
959 const overlaps = tasksOverlap([...existing.filter(t => t.id !== r.id), task]).filter(o => o.a.id === r.id || o.b.id === r.id)
960 await saveTasks($, list => [...list.filter(t => t.id !== r.id), task])
961 void recordUsage($, 'task_add')
962 const warn = overlaps.length
963 ? ` Warning: overlaps ${overlaps.map(o => `${o.a.id === r.id ? o.b.id : o.a.id} on ${o.on}`).join('; ')}. Narrow the paths or sequence the work.`
964 : ''
965
966 return `Task ${r.id} owns ${r.paths.join(', ')} on ${r.branch || 'any branch'} (saved to ${home(tasksFile())}).${warn}`
967}
968
969async function finishTask($: any, id: string): Promise<string> {
970 const list = await loadTasks($)
971 if (!list.some(t => t.id === id && t.status === 'active')) return `No active task "${id}".`
972 await saveTasks($, l => l.map(t => (t.id === id ? { ...t, status: 'done' as const } : t)))
973 void recordUsage($, 'task_done')
974
975 return `Task ${id} marked done; its paths are released.`
976}
977
978function tasksText(tasks: TaskRow[], branch: string) {
979 const active = tasks.filter(t => t.status === 'active')
980 if (active.length === 0) return 'No tasks declared. /task add <ID> <paths> [--branch <name>] [note]'
981 const overlaps = tasksOverlap(active)
982
983 return [
984 ...active.map(t => `- ${t.id} | ${t.paths.join(', ')} | ${t.branch || 'any branch'}${t.branch !== '' && t.branch === branch ? ' (this branch)' : ''}${t.owner ? ` | ${t.owner}` : ''}${t.note ? ` | ${t.note}` : ''}`),
985 ...overlaps.map(o => `! ${o.a.id} and ${o.b.id} both claim ${o.on}`),
986 ].join('\n')
987}
988
989const fmtTok = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(Math.round(n)))
990
991const bar = (pct: number, width = 20) => {
992 const filled = Math.round((Math.min(100, Math.max(0, pct)) / 100) * width)
993
994 return '█'.repeat(filled) + '░'.repeat(width - filled)
995}
996
997const level = (pct: number) => (pct >= 85 ? 'red' : pct >= 60 ? 'yellow' : 'green')
998
999const untilReset = (iso: string, now: number) => {
1000 const ms = Date.parse(iso) - now
1001 if (!Number.isFinite(ms) || ms <= 0) return 'now'
1002 const mins = Math.round(ms / 60000)
1003
1004 return mins < 60 ? `${mins}m` : mins < 1440 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${Math.floor(mins / 1440)}d ${Math.floor((mins % 1440) / 60)}h`
1005}
1006
1007const LIMIT_LABEL: Record<string, string> = { five_hour: '5-hour window', seven_day: '7-day window', spend_limit: 'spend limit' }
1008
1009// Reads only the bytes a transcript gained since the last look and sums the assistant messages'
1010// token usage. One message is written as several lines with the same id and the same usage, so
1011// the last line per id counts once. Output is one JSON line.
1012const USAGE_SCRIPT = [
1013 'import sys, json',
1014 'path, off = sys.argv[1], int(sys.argv[2])',
1015 'try:',
1016 ' f = open(path, "rb"); f.seek(0, 2); size = f.tell()',
1017 ' if off > size: off = 0',
1018 ' f.seek(off); data = f.read(); f.close()',
1019 'except Exception:',
1020 ' print(json.dumps({"ok": False})); sys.exit()',
1021 'end = data.rfind(b"\\n")',
1022 'used = data[: end + 1] if end >= 0 else b""',
1023 'by_id = {}',
1024 'for line in used.split(b"\\n"):',
1025 ' if b\'"usage"\' not in line: continue',
1026 ' try: d = json.loads(line)',
1027 ' except Exception: continue',
1028 ' m = d.get("message") or {}',
1029 ' u = m.get("usage")',
1030 ' if d.get("type") != "assistant" or not isinstance(u, dict): continue',
1031 ' by_id[m.get("id") or d.get("uuid")] = (u, m.get("model") or "")',
1032 'inp = out = cc = cr = last = 0; model = ""',
1033 'for u, mo in by_id.values():',
1034 ' i = u.get("input_tokens") or 0; o = u.get("output_tokens") or 0',
1035 ' a = u.get("cache_creation_input_tokens") or 0; b = u.get("cache_read_input_tokens") or 0',
1036 ' inp += i; out += o; cc += a; cr += b; last = i + a + b; model = mo or model',
1037 'print(json.dumps({"ok": True, "off": off + len(used), "inp": inp, "out": out, "cc": cc, "cr": cr, "turns": len(by_id), "last": last, "model": model}))',
1038].join('\n')
1039
1040async function scanTranscript($: any, sessionId: string, cwd: string) {
1041 const prev = S.usageCache.get(sessionId) ?? { off: 0, inp: 0, out: 0, cc: 0, cr: 0, turns: 0, last: 0, model: '' }
1042 const file = `${P.projects}/${cwd.replace(/[^a-zA-Z0-9]/g, '-')}/${sessionId}.jsonl`
1043 let r: any
1044 for (const py of S.py !== '' ? [S.py] : ['python3', 'python']) {
1045 r = await $.process.run([py, '-c', USAGE_SCRIPT, file, String(prev.off)], { timeoutMs: 90000 }).catch(() => undefined)
1046 if (r && r.exitCode === 0) {
1047 S.py = py
1048 break
1049 }
1050 }
1051 S.pyFailed = !r || r.exitCode !== 0
1052 if (!r || r.exitCode !== 0) return prev
1053 try {
1054 const d = JSON.parse((r.stdout as string).trim().split('\n').pop() ?? '{}')
1055 if (!d.ok) return prev
1056 // After a rewind to 0 the file is a different one: drop the old totals.
1057 const base = d.off - 0 < prev.off ? { ...prev, inp: 0, out: 0, cc: 0, cr: 0, turns: 0 } : prev
1058 const next = {
1059 off: d.off, inp: base.inp + d.inp, out: base.out + d.out, cc: base.cc + d.cc, cr: base.cr + d.cr,
1060 turns: base.turns + d.turns, last: d.turns > 0 ? d.last : base.last, model: d.model || base.model,
1061 }
1062 S.usageCache.set(sessionId, next)
1063
1064 return next
1065 } catch {
1066 return prev
1067 }
1068}
1069
1070async function usageTick($: any) {
1071 if (S.usageBusy || S.sessionId === '') return
1072 S.usageBusy = true
1073 try {
1074 const now: number = await $.clock.now()
1075 let limits: LimitRow[] = []
1076 let myWindow = 0
1077 let myCtx: number | undefined
1078 let myCost = 0
1079 S.usageAttemptAt = now
1080 let limitsFrom = ''
1081 let limitsAt = 0
1082 let hostNote = ''
1083 try {
1084 const u = await $.session.usage()
1085 limits = (u.rateLimits ?? []).map((l: any) => ({ kind: String(l.kind), percentUsed: Number(l.percentUsed) || 0, resetsAt: String(l.resetsAt ?? '') }))
1086 myWindow = Number(u.context?.window) || 0
1087 myCtx = typeof u.context?.tokens === 'number' ? u.context.tokens : undefined
1088 myCost = Number(u.cost?.usd) || 0
1089 if (limits.length === 0) hostNote = 'this host reported no plan limits yet (they appear after an API response on a subscription plan)'
1090 } catch (err) {
1091 hostNote = `this host does not expose plan limits (${String(err).slice(0, 60)})`
1092 }
1093 // Plan limits are account-wide: whichever session can read them shares them; the others show the latest copy.
1094 if (limits.length > 0) {
1095 S.limitsSeen = true
1096 limitsFrom = S.myName || short(S.sessionId)
1097 limitsAt = now
1098 await $.fs.write(P.limits, JSON.stringify({ at: now, from: limitsFrom, limits })).catch(() => undefined)
1099 } else {
1100 try {
1101 const shared = JSON.parse(await $.fs.read(P.limits))
1102 if (shared && Array.isArray(shared.limits) && shared.limits.length > 0 && now - Number(shared.at) < 6 * 60 * 60 * 1000) {
1103 limits = shared.limits
1104 limitsFrom = String(shared.from ?? 'another session')
1105 limitsAt = Number(shared.at) || 0
1106 S.limitsSeen = true
1107 }
1108 } catch {
1109 // nothing shared yet
1110 }
1111 }
1112
1113 const targets: { sessionId: string; name: string; cwd: string; isSelf: boolean }[] = [
1114 { sessionId: S.sessionId, name: S.myName || short(S.sessionId), cwd: S.myCwd || S.root, isSelf: true },
1115 ...(await read($, peers)).map((p: PeerRow) => ({ sessionId: p.sessionId, name: p.name, cwd: p.cwd, isSelf: false })),
1116 ]
1117 const rows: UsageRow[] = []
1118 for (const tg of targets) {
1119 const t = await scanTranscript($, tg.sessionId, tg.cwd)
1120 const ctx = tg.isSelf && myCtx !== undefined ? myCtx : t.last
1121 const window = tg.isSelf && myWindow > 0 ? myWindow : ctx > 200000 ? 1000000 : 200000
1122 rows.push({
1123 sessionId: tg.sessionId, name: tg.name, isSelf: tg.isSelf, ctx, window,
1124 isEstimate: !(tg.isSelf && myWindow > 0), turns: t.turns, output: t.out, input: t.inp,
1125 cacheCreate: t.cc, cacheRead: t.cr, model: t.model, costUsd: tg.isSelf ? myCost : 0,
1126 })
1127 }
1128 rows.sort((a, b) => Number(b.isSelf) - Number(a.isSelf) || b.ctx / b.window - a.ctx / a.window)
1129
1130 const info: UsageInfo = { at: now, limits, rows, limitsFrom, limitsAt, hostNote }
1131 await update($, usageAtom, (old: UsageInfo | null) => (same(old, info) ? old : info))
1132
1133 // Warn once when a session's context, or a plan window, crosses 80% / 95%.
1134 for (const r of rows) {
1135 const pct = r.window > 0 ? (r.ctx / r.window) * 100 : 0
1136 const step = pct >= 95 ? 95 : pct >= 80 ? 80 : 0
1137 const prev = S.ctxWarned.get(r.sessionId) ?? 0
1138 if (step > prev && S.usageReady) {
1139 $.ui.toast(`⚠ ${r.isSelf ? 'this session' : r.name}: context ${Math.round(pct)}% full (${fmtTok(r.window - r.ctx)} left)`, { timeoutMs: 12000 })
1140 }
1141 S.ctxWarned.set(r.sessionId, step)
1142 }
1143 for (const l of limits) {
1144 for (const step of [80, 95]) {
1145 const key = `${l.kind}:${step}:${l.resetsAt}`
1146 if (l.percentUsed >= step && !S.limitWarned.has(key)) {
1147 S.limitWarned.add(key)
1148 if (S.usageReady) $.ui.toast(`⚠ ${LIMIT_LABEL[l.kind] ?? l.kind} ${Math.round(l.percentUsed)}% used`, { timeoutMs: 12000 })
1149 }
1150 }
1151 }
1152 S.usageReady = true
1153 } finally {
1154 S.usageBusy = false
1155 }
1156}
1157
1158async function observe($: any, r: any, now: number, isSelf: boolean) {
1159 const status: string = r.status ?? '?'
1160 const before = S.lastStatus.get(r.sessionId)
1161 S.lastStatus.set(r.sessionId, status)
1162 const at: number = r.statusUpdatedAt ?? now
1163 const name: string = r.name ?? short(r.sessionId)
1164 if (status === 'busy' && before !== 'busy') S.busySince.set(r.sessionId, at)
1165 if (before !== undefined && before !== status && !isSelf) await notifyChange($, r.sessionId, name, before, status)
1166 if (before !== 'busy' || status === 'busy') return
1167
1168 const started = S.busySince.get(r.sessionId)
1169 const cached = S.activityOf.get(r.sessionId)
1170 S.busySince.delete(r.sessionId)
1171 await recordFinish($, {
1172 id: `${short(r.sessionId)}-${at}`,
1173 sessionId: r.sessionId,
1174 name,
1175 outcome: status,
1176 at,
1177 durationMs: started === undefined ? 0 : Math.max(0, at - started),
1178 branch: isSelf ? S.branch : (S.projectOf.get(r.cwd) !== undefined ? await git($, r.cwd, 'rev-parse', '--abbrev-ref', 'HEAD') : ''),
1179 task: isSelf ? S.prompt : cached?.task ?? '',
1180 activity: isSelf ? '' : cached?.activity ?? '',
1181 })
1182}
1183
1184async function notifyChange($: any, sessionId: string, name: string, before: string, status: string) {
1185 const pref: Notify = fixUi(await read($, uiAtom)).notify
1186 const isPinned = (await read($, pinsAtom)).some((pin: PinRow) => pin.sessionId === sessionId)
1187 const star = isPinned ? '★ ' : ''
1188 const extra = isPinned ? 4000 : 0
1189 if (status === 'busy') {
1190 if (pref.start || isPinned) $.ui.toast(`${star}▶ ${name} started working`, { timeoutMs: 6000 + extra })
1191 } else if (status === 'idle') {
1192 if (pref.done || isPinned) $.ui.toast(`${star}✓ ${name} is idle, waiting for your next task`, { timeoutMs: 10000 + extra })
1193 } else if (status === 'shell') {
1194 if (pref.done || isPinned) $.ui.toast(`${star}✓ ${name} finished its turn (background shell still running)`, { timeoutMs: 8000 + extra })
1195 } else if (pref.wait || isPinned) {
1196 $.ui.toast(`${star}⏸ ${name} needs you (${status})`, { timeoutMs: 12000 + extra })
1197 }
1198}
1199
1200async function toggleNotify($: any, key: keyof Notify) {hooks/tasks.ts 122 lines1// Task-level ownership: a task owns path patterns on a branch and outlives any one session.
2// Stored at <main worktree>/.guardian/tasks.json so every worktree of the repo shares it.
3// Pure logic lives here so it can be unit-tested without the engine.
4
5import type { TaskRow } from '../types'
6
7export const TASKS_VERSION = 1
8
9/** Glob match with the usual semantics: `*` within one segment, `**` across segments
10 * (including zero), `?` one char. A pattern without wildcards matches itself or anything under it. */
11export function globMatches(pattern: string, rel: string): boolean {
12 const p = pattern.trim().replace(/^\.\//, '').replace(/\/+$/, '')
13 if (p === '' || rel === '') return false
14 if (!/[*?]/.test(p)) return rel === p || rel.startsWith(p + '/')
15 let re = ''
16 for (let i = 0; i < p.length; i++) {
17 const c = p[i]
18 if (c === '*') {
19 if (p[i + 1] === '*') {
20 i++
21 if (p[i + 1] === '/') {
22 i++
23 re += '(?:.*/)?'
24 } else re += '.*'
25 } else re += '[^/]*'
26 } else if (c === '?') re += '[^/]'
27 else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
28 }
29
30 return new RegExp(`^${re}$`).test(rel)
31}
32
33export const patternsOverlap = (a: string, b: string) => globMatches(a, b) || globMatches(b, a) || a === b
34
35/** Where the shared task file lives for a repo whose git common dir is `gitCommonDir`
36 * (`.git` of the main worktree). */
37export function taskFilePath(gitCommonDir: string): string {
38 const clean = gitCommonDir.replace(/\/+$/, '')
39 const parent = clean.endsWith('/.git') ? clean.slice(0, -'/.git'.length) : clean
40
41 return `${parent}/.guardian/tasks.json`
42}
43
44export function parseTasks(text: string): TaskRow[] {
45 let parsed: unknown
46 try {
47 parsed = JSON.parse(text)
48 } catch {
49 return []
50 }
51 const list = Array.isArray(parsed) ? parsed : parsed && typeof parsed === 'object' && Array.isArray((parsed as { tasks?: unknown }).tasks) ? (parsed as { tasks: unknown[] }).tasks : []
52
53 return list.flatMap((row: unknown) => {
54 if (!row || typeof row !== 'object') return []
55 const r = row as Record<string, unknown>
56 if (typeof r.id !== 'string' || r.id === '' || !Array.isArray(r.paths)) return []
57 const paths = r.paths.filter((x): x is string => typeof x === 'string' && x.trim() !== '').map(x => x.trim())
58 if (paths.length === 0) return []
59 const task: TaskRow = {
60 id: r.id,
61 paths,
62 branch: typeof r.branch === 'string' ? r.branch : '',
63 owner: typeof r.owner === 'string' ? r.owner : '',
64 note: typeof r.note === 'string' ? r.note : '',
65 createdAt: typeof r.createdAt === 'number' ? r.createdAt : 0,
66 status: r.status === 'done' ? 'done' : 'active',
67 }
68
69 return [task]
70 })
71}
72
73export function serializeTasks(tasks: readonly TaskRow[]): string {
74 return JSON.stringify({ version: TASKS_VERSION, tasks }, null, 2) + '\n'
75}
76
77/** The active task whose declared paths cover `rel` while `branch` is not the task's branch.
78 * Tasks without a branch are informational and never block. */
79export function findViolation(tasks: readonly TaskRow[], rel: string, branch: string, allowed: ReadonlySet<string> = new Set()): TaskRow | undefined {
80 return tasks.find(t => t.status === 'active' && t.branch !== '' && t.branch !== branch && !allowed.has(t.id) && t.paths.some(p => globMatches(p, rel)))
81}
82
83/** Pairs of active tasks whose declared patterns overlap: the preflight check. */
84export function tasksOverlap(tasks: readonly TaskRow[]): { a: TaskRow; b: TaskRow; on: string }[] {
85 const active = tasks.filter(t => t.status === 'active')
86 const out: { a: TaskRow; b: TaskRow; on: string }[] = []
87 for (let i = 0; i < active.length; i++) {
88 for (let j = i + 1; j < active.length; j++) {
89 const a = active[i]
90 const b = active[j]
91 const hit = a.paths.find(pa => b.paths.some(pb => patternsOverlap(pa, pb)))
92 if (hit !== undefined) out.push({ a, b, on: hit })
93 }
94 }
95
96 return out
97}
98
99const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$/
100
101/** `/task add <ID> <path[,path]> [--branch <name>] [note…]` */
102export function parseTaskAdd(args: string, defaultBranch: string): { ok: true; id: string; paths: string[]; branch: string; note: string } | { ok: false; msg: string } {
103 const tokens = args.trim().split(/\s+/).filter(Boolean)
104 const usage = 'Usage: /task add <ID> <path[,path...]> [--branch <name>] [note]'
105 const id = tokens.shift()
106 const pathToken = tokens.shift()
107 if (id === undefined || pathToken === undefined) return { ok: false, msg: usage }
108 if (!ID_RE.test(id)) return { ok: false, msg: `Task id "${id}" must be 1-40 letters, digits, dot, dash or underscore.` }
109 const paths = [...new Set(pathToken.split(',').map(p => p.trim().replace(/^\.\//, '').replace(/\/+$/, '')).filter(Boolean))].slice(0, 20)
110 if (paths.length === 0) return { ok: false, msg: usage }
111 let branch = defaultBranch
112 const at = tokens.indexOf('--branch')
113 if (at !== -1) {
114 const b = tokens[at + 1]
115 if (b === undefined || b.startsWith('--')) return { ok: false, msg: '--branch needs a branch name.' }
116 branch = b
117 tokens.splice(at, 2)
118 }
119
120 return { ok: true, id, paths, branch, note: tokens.join(' ').slice(0, 200) }
121}
122types/index.d.ts 145 lines1export type AgentRow = { id: string; label: string; status: string; parentId?: string }
2
3export type SubRow = { id: string; label: string; type: string; isRunning: boolean; at: number }
4
5export type PeerRow = {
6 sessionId: string
7 pid: number
8 name: string
9 kind: string
10 status: string
11 cwd: string
12 branch: string
13 isSameProject: boolean
14 updatedAt: number
15 files: string[]
16 overlap: string[]
17 task: string
18 activity: string
19 tty: string
20 jobId: string
21 editAt: Record<string, number>
22 subs: SubRow[]
23}
24
25export type WorktreeRow = { path: string; branch: string; dirty: number; isHere: boolean }
26
27export type GitInfo = {
28 branch: string
29 dirty: number
30 ahead: number
31 behind: number
32 base: string
33 worktrees: WorktreeRow[]
34 merged: string[]
35}
36
37export type NoteRow = { id: string; text: string; done: boolean; at: number }
38
39export type HistoryRow = {
40 id: string
41 sessionId: string
42 name: string
43 outcome: string
44 at: number
45 durationMs: number
46 branch: string
47 task: string
48 activity: string
49}
50
51export type PinRow = { sessionId: string; name: string }
52
53export type MessageRow = {
54 id: string
55 at: number
56 fromId: string
57 fromName: string
58 toId: string
59 toName: string
60 text: string
61 hop: number
62 via: string
63 delivered: boolean
64}
65
66export type ClaimRow = {
67 id: string
68 sessionId: string
69 name: string
70 paths: string[]
71 note: string
72 at: number
73 expiresAt: number
74}
75
76export type TaskRow = {
77 id: string
78 paths: string[]
79 branch: string
80 owner: string
81 note: string
82 createdAt: number
83 status: 'active' | 'done'
84}
85
86export type LimitRow = { kind: string; percentUsed: number; resetsAt: string }
87
88export type UsageRow = {
89 sessionId: string
90 name: string
91 isSelf: boolean
92 ctx: number
93 window: number
94 isEstimate: boolean
95 turns: number
96 output: number
97 input: number
98 cacheCreate: number
99 cacheRead: number
100 model: string
101 costUsd: number
102}
103
104export type UsageInfo = { at: number; limits: LimitRow[]; rows: UsageRow[]; limitsFrom: string; limitsAt: number; hostNote: string }
105
106export type Tab = 'overview' | 'sessions' | 'git' | 'notes' | 'history' | 'messages' | 'usage' | 'actions'
107
108export type Notify = { start: boolean; done: boolean; wait: boolean }
109
110export type UiState = {
111 tab: Tab
112 busyOnly: boolean
113 hideBg: boolean
114 flash: string
115 detail: string[]
116 notify: Notify
117 precheck: boolean
118 sharePrompt: boolean
119 theme: 'auto' | 'light' | 'dark'
120 jumpAuto: boolean
121 /** session ids whose card is expanded in the Sessions tab */
122 expanded: string[]
123}
124
125export type SelfRow = { sessionId: string; root: string; branch: string; note: string }
126
127declare module 'claude-code' {
128 interface PluginState {
129 'session-agents': {
130 agents: AgentRow[]
131 peers: PeerRow[]
132 me: SelfRow | null
133 git: GitInfo | null
134 ui: UiState
135 notes: NoteRow[]
136 history: HistoryRow[]
137 pins: PinRow[]
138 messages: MessageRow[]
139 claims: ClaimRow[]
140 tasks: TaskRow[]
141 usage: UsageInfo | null
142 }
143 }
144}
145