SLOPSHOPPER

session-agents

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…

newpanebandguardcommandtoast
★ 1v0.18.1PolyForm-Shield-1.0.0updated 2026-10-24d10or/workspace-guardian/plugins/session-agents
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-agents
│ ┃ Control tower ✕ › fix the failing auth test and add an audit log call │ ┃ ✓ all clear │ app ⎇ feat/auth-refresh ±2 … │ ┃ updated 08:53:20 · every 5s · v0.18.1 · pre… ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [ ● Overview ] [ ○ Sessions 0 ] [ ○ Git ] [ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ╭──────────────────────────────────────────╮ ⏺ Bash(bun test) │ ┃ │ ✓ Nothing needs attention │ ⎿ 3 pass, 1 fail │ ┃ │ no overlaps, no stale branches, nothing │ │ ┃ │ waiting. Declared tasks and live claims │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ are checked on every edit. │ │ ┃ ╰──────────────────────────────────────────╯ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ ╭──────────────────────────────────────────╮ › /tell │ ┃ │ Ownership 0 tasks · 0 claims │ ⎿ session-agents: Usage: /tell <name-or-session-id-prefix> <messag │ ┃ │ Declare which paths each piece of work │ │ ┃ │ owns. Another session editing them from │ │ ┃ │ a different branch is held and you are │ │ ┃ │ asked first. │ │ ┃ │ /task add AUTH 'src/auth/**' --branch │ │ ┃ │ feature/auth │ │ ┃ │ /claim src/db (short-lived, this │ │ ┃ │ session only) │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ │ ┃ ╭──────────────────────────────────────────╮ ⎇ feat/auth-refresh ±2 │ 0 sessions │ 0 worktrees │ /sessions ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⎇ feat/auth-refresh ±2 │ 0 sessions │ 0 worktrees │ /sessions
Pane · Control tower
✓ all clear │ app ⎇ feat/auth-refresh ±2 │ 1 session ·… updated 08:53:20 · every 5s · v0.18.1 · preview- [ ● Overview ] [ ○ Sessions 0 ] [ ○ Git ] [ ○ History ] [ ○ ╭──────────────────────────────────────────────────────────╮ │ ✓ Nothing needs attention │ │ no overlaps, no stale branches, nothing waiting. │ │ Declared tasks and live claims are checked on every │ │ edit. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Ownership 0 tasks · 0 claims │ │ Declare which paths each piece of work owns. Another │ │ session editing them from a different branch is held and │ │ you are asked first. │ │ /task add AUTH 'src/auth/**' --branch feature/auth │ │ /claim src/db (short-lived, this session only) │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Working now 0 │ │ no other session is busy. Sessions tab lists every live │ │ session on this machine. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Subagents 0 │ │ none running │ ╰──────────────────────────────────────────────────────────╯ ● busy ○ idle ★ pinned ◆ task 🔒 claim ⚠ risk ⏸ held ✓ clear [ Refresh ] [ Fetch origin ]
README

Multi-Agent Workspace Guardian

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.

See it

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.

guardian check finding overlaps across worktrees

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

guardian inspect and check on a clean repo

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.

guardian handoff before and after running tests

Reproduce them with bash demo/scenarios.sh (see docs/DEMO.md).

What is here today

PathWhat 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.jsonMakes this repository a plugin marketplace.
LICENSEPolyForm 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.shUserPromptSubmit 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.

Install the mod

/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.

Install the shared-checkout hook

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\"" }
] } ] } }

The 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.

CommandEffect
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.

Development

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

Layout

.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-ignored
Source 3 files
hooks/register.tsx 2167 lines
1import { 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 lines
1// 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}
122
types/index.d.ts 145 lines
1export 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