Team Orchestrator: welcome screen, quick starts (Squad, All-Purpose Team, Tech Team), a table-style team form, and a live roster to spawn and manage teams of…

Team Orchestrator runs a team of Claude Code sessions for you. Each member is its own Claude Code session in its own Orca tab. You build the team, watch it and change it from a panel above the prompt. The members message each other, start when they are first needed, close when they have been idle, and reopen when a message arrives.
A guard keeps the members inside their roles: members with reports plan and delegate instead of writing files, and nobody starts subagents unless you allow it.
| Feature | What it does |
|---|---|
| Team builder | Quick starts (Squad, All-Purpose Team, Tech Team), a team form, or an interview that designs the team with you |
| Live roster | One card per team: state, context use, model, effort and briefing for every member |
| Org chart | A live chart whose dots follow each member's state |
| Layouts | Stacked, side by side, or docked beside the transcript |
| On-demand start | Leads and workers start the first time their boss messages them |
| Role files | One file per member: a generated role, plus your own notes that the mod never changes |
| Team messaging | team_message starts or reopens a member, then delivers. @team sends your message to a team's head. |
| Adding members | New members on a running team, within a lead's purview and with your approval (0.5.17) |
| Auto-close and reopen | Idle workers close; a message reopens them with their conversation |
| Session cap and queue | A limit on open sessions; messages wait until there is room |
| Permission guard | No subagents for members, and no file writes for members with reports, unless you allow it |
| Locked team files | Only the team top changes the roster and the team settings |
| Identity tracking | Members stay recognised after /clear, /rename or a fresh claude; unclear sessions are held for you |
| Crash and sleep handling | Waking up closes nothing; only members proved to have crashed are reopened |
| Two computers | A team can span computers that share one project folder |
| Other CLIs | Tabs that run another CLI can sit on the roster, unmanaged |
| Windows, macOS and Linux | Every platform Orca runs on |
| What | Details |
|---|---|
| Claude Code | A build that loads mods (plugins with hooks). Every member runs claude in its own tab. |
| Orca | Orca must be running, and its command-line tool must work. The mod opens, closes and switches tabs through it. |
| Operating system | Windows, macOS or Linux. On Windows the mod uses PowerShell for process checks. On macOS and Linux it uses ps and tail. |
| git (optional) | When the project is a git repository, the mod keeps its team folder out of commits. |
The mod has one option, Orca command (orcaCommand). You find it in /config, or with claude plugin configure team-orchestrator.
| Value | What happens |
|---|---|
| Empty (the default) | At the next start the mod tries orca.exe on Windows or orca on macOS and Linux. If --version works, it saves that value. |
| A command or a full path | The mod checks it at every start. When you edit the option, the mod refuses a command that does not start and shows the reason. |
| A saved value that fails on this computer | If the platform default works, the mod switches to it and shows a toast. This covers settings synced from another platform. |
claude plugin marketplace add herman925/925-cc-pluginsclaude plugin install team-orchestrator@herman-mods --scope user/reload-plugins.claude plugin marketplace update herman-modsclaude plugin update team-orchestrator@herman-mods/reload-plugins in every open session of the team. Reload the team top first, then the others.Why the order matters: the team top stamps the team files with its version. A session whose mod is older than that stamp writes no team file. It shows a toast once and waits until you update and reload it. A session that has not reloaded yet may also show as offline in the updated top's panel until it reloads.
The version in the band and in the panel header shows what each session has loaded.
t, or type /team. The panel opens above the prompt.@<team name> and your message in your own session. The message goes to that team's head.Only the top and the team heads start at Launch. Everyone else starts the first time their boss messages them.
The guard watches every session that is on the roster. A session that is not on the roster, such as your own, is never judged. A subagent runs inside its session, so a rule for the session covers its subagents too.
| Tool | Who is refused | Unless |
|---|---|---|
Agent (subagents) | Every roster member | Allow subagents is on, or #allow-subagent this turn. Claude Code's own helpers statusline-setup and claude-code-guide always pass. |
Write, Edit, NotebookEdit | Members with reports (the top, heads and leads) | Allow writes is on, or #allow-write this turn. The member's own memory folder (~/.claude/projects/<project>/memory/) is always open. |
Write, Edit, NotebookEdit on the team files | Every member except the team top | No switch or keyword opens them. |
Bash, PowerShell commands that name the team files | Every member except the team top | Plain reads pass: cat, type, Get-Content, ls, dir, grep, Select-String, jq without -i. |
The team files are roster.json, settings.json, meta.json, the changes/ folder and, from 0.5.17, the requests/ folder. Request files are written only through member_add, never by hand. roles/ and queue/ stay writable. Bash and PowerShell are otherwise not guarded.
Each refusal says who was refused and why, and tells the model to hand the work to a worker or to ask you. It also shows a toast. A permission that lets a call through shows a toast only once per session and reason.
A session on hold (see Identity and member_claim) gets the strictest guard: no writes and no subagents without a one-turn keyword, and no team tools at all.
| Way | How long | How |
|---|---|---|
| Standing switch | Until you switch it off | Settings → Permissions → Allow subagents / Allow writes, per member. Stored in the roster, so it counts at once in every session. |
| One-turn keyword | The current turn | Type #allow-subagent or #allow-write in your own message to that member's session. |
The keywords count only in a prompt typed at that session's own prompt. The same words in a message from another session, in a tool result or in another plugin's prompt allow nothing. A later prompt of yours without the keyword takes the grant back, and the grant ends with the turn. A reload forgets it.
A subagent or a workflow agent is judged as the member that started it, by the grants standing right now. A one-turn keyword lasts until the spawning member's own turn ends.
The mod records what it last wrote for each member's two switches. If the roster file shows a different value on two refreshes in a row, the team top gets a toast and the member's row gets a note. Nothing is reverted.
The roster is .claude/team-orchestrator/roster.json. Only one session writes it, and settings.json: the team top's session, on the team top's computer. The guard refuses every other member's tools and shell commands on these files (see the table above).
changes/ with only the fields it changed. Its own panel shows the change at once.changes/applied.json. Change files older than 7 days are ignored.meta.json records the schema version, the version of the mod that last wrote the roster, and the team top's computer. A session whose mod is older than that version writes no team file.requests/ are locked the same way: no member can write them with Write, Edit or the shell, and only member_add creates them. Before the team top applies a request, it checks again that the member who asked is still on the roster and still allowed to ask for that team.| The mod can | The mod does not |
|---|---|
| Open, close and switch Orca tabs of this project's members on this computer | Close or reopen members of other computers, or members that run another CLI |
Start claude sessions for members, with their role pointer | Kill processes. Members are told to close their own leftovers. |
Write in .claude/team-orchestrator/, in its status folder on this computer, and in the repository's info/exclude | Delete files. Applied change files and queue entries stay, listed in an index. |
| Read session transcripts (title, requested model, last model, effort, context use and working folder only) | Keep or show anything else from a transcript |
| Read the session registry and look up member processes by id | Poll processes on a timer |
| Approve a plain delete of a member's own scratch files | Override a deny from your rules or your organisation |
| Send messages to members by session id | Message sessions of other projects or Remote Control copies |
With Scratch: Auto-approve clean-up on, the mod approves some deletes that Claude Code would otherwise ask about.
| Member | May delete without asking |
|---|---|
| Worker | Inside its own session's folder in the Claude temp folder |
| Head or lead | The above, plus anything in the system temp folder and in the project's scratch folder (Scratch dir) |
Limits:
*, ?, [, ]) or a trailing slash asks.Per session. These change only the look of your own panel. They survive /reload-plugins, not a new session.
| Setting | What it does | Default |
|---|---|---|
| Layout | Stacked, Side by side or Dock right. See Layouts. | Stacked |
| Org chart | Shows or hides the live org chart above the cards | Show |
| Columns | Shows or hides STATUS, CONTEXT, MODEL, EFFORT and BRIEF. NAME always shows. | All shown |
Team-wide (Settings → Workers). Shared by every session, in .claude/team-orchestrator/settings.json.
| Setting | What it does | Default |
|---|---|---|
| Auto-close | Closes workers that said "clean" and stayed idle (On, Off) | On |
| Idle minutes | How long a worker stays idle before it is closed (5, 10, 20, 30, 60) | 10 |
| Reopen as | Resume keeps the conversation; Fresh starts a new session that reads its role file again | Resume |
| Max open | Most of this computer's members open at once (No cap, 6, 8, 10, 12) | 8 |
| Launch | On demand starts only the top and the team heads; All at Create starts everyone | On demand |
| Batch size | Sessions started at once at Launch (1, 2, 3, 4, 6); the next batch follows 5 seconds after the last is ready | 3 |
| Scratch | Auto-approve clean-up, or Ask each time. See Scratch clean-up. | Auto-approve |
| Scratch dir | The project's scratch folder that heads and leads may clean without asking | .claude/scratch |
| Never close | A tick box per worker; a ticked worker is never auto-closed | None ticked |
Per member, team-wide (Settings → Permissions). Stored in the roster.
| Setting | What it does | Default |
|---|---|---|
| Allow subagents | Lets this member use the Agent tool | Off |
| Allow writes | Lets this member, if it has reports, use Write, Edit and NotebookEdit | Off |
Plugin option.
| Setting | What it does | Default |
|---|---|---|
Orca command (orcaCommand) | Orca's command-line tool, or its full path. See Requirements. | Empty: detected and saved at the next start |
A project can hold several teams. Each member has a name, a role, a level and a boss.
| Term | Who it is |
|---|---|
| Team top | The member that reports to you (its boss is user). In a one-team setup this is the head. Under a CEO it is the CEO. If several members report to you, the first one on the roster is the team top. |
| Head | The member at the top of one team. Under a CEO, each team has its own head, which reports to the CEO. |
| Lead | A member with its own reports, below a head. Leads appear in three-level teams. |
| Worker | A member with no reports. Workers do the tasks. |
Members with reports (the top, heads and leads) plan, delegate and review. Workers do the work and report back to their boss only.
Member names are unique across the project. When a new team reuses a name that another team has, the new member gets its team name in front (Team2-Head).
Each member has a role file: .claude/team-orchestrator/roles/<name>.md. It has two parts.
| Part | Contents | Who writes it |
|---|---|---|
| Above the marker | The member's job for its level, its boss and reports, how to message, housekeeping, the whole team, and the team files | The mod. It rewrites this part whenever the team changes. |
| Personality and notes, below the marker | A voice, a working style or extra rules for this member | You. The mod never changes this part. |
Every member starts with a short pointer in its system prompt (--append-system-prompt). The pointer gives its name, its boss, the one rule for its level, and the path of its role file. The member reads the file before its first action. When the file changes, Claude Code tells the session what changed.
Launch writes the whole roster first. Then it starts sessions in batches.
team_message.Each batch starts, waits until the sessions are ready, and then the next batch follows 5 seconds later. The batch size is 3 by default. Both options are in Settings → Workers.
team_message and SendMessage| Tool | Use it for | Why |
|---|---|---|
team_message | A boss messaging its own reports | It starts a member that has not started yet, or reopens one that was closed, and then delivers. It addresses the member by session id, so it never reaches a same-named session of another project or a Remote Control copy. |
SendMessage | A worker reporting to its boss, and heads and leads talking to each other | It is Claude Code's own tool. It only knows sessions that are running now. |
The mod adds a note to the SendMessage description that says when to use team_message instead. For roster members, team_message is listed up front, not behind ToolSearch.
In your own session, type @<team name> and your message. The mod sends the message to that team's head and keeps your session out of the conversation. A head on this computer with a known session id is addressed by that id.
To rename a team, type a new name in the team card's Team name field, or use /team name <old> <new>. The @ mention changes with it.
Each member writes its own status file on its own computer:
~/.claude/team-orchestrator/<project key>/status/<name>.json
~/.claude is your Claude config folder (CLAUDE_CONFIG_DIR when set). The project key is the project path with every character except letters and digits turned into -.
A member writes its status when a turn starts and ends, when it asks you a question, and on a 60-second heartbeat. The file holds its state, model, effort, context use and window, its current task line, its last "clean", and its count of leftover processes. Nobody reads other members' screens. Only the team top (or your own session, when it is not on the roster) asks Orca whether tabs still exist, and only when someone has been silent for over 2 minutes. The check runs at most every 2 minutes, and less often while Orca answers slowly.
Every member records its home computer (its computer name) at launch, adopt, reopen and identity re-attach.
team_message to it answers that its conversation lives on that computer. With your yes, startHere: true starts a fresh copy here, and this computer becomes its home.meta.json records the team top on another computer, the team-top work here is off: writing the team files, auto-close and the queue. A toast names the computer that holds the top. The team top's session here asks you whether to take over, and applies your answer with team_take_top.member_claimA member stays recognised after /clear, /rename or a fresh claude in its tab. Each session compares three facts with the roster: its Orca tab, its session id and its session name.
| Facts that match | Result |
|---|---|
| Tab, id and name | The member |
Tab and id, new name (/rename) | The member. The roster, its reports, its role file and its status file take the new name. |
Tab and name, new id (/clear) | The member. The roster takes the new id. |
| Id and name, another tab | The member if its old tab is gone; on hold if the old tab is still open |
Tab only (a fresh claude in the tab) | The member, re-attached. Its next prompt tells it to read its role file. |
| Id only, or name only | On hold |
| Nothing | Not on the team. The guard leaves it alone. |
Outside Orca, the session registry (~/.claude/sessions/) stands in for the tab, but only beside an id or a name. A /rename to another member's name is not followed.
A session on hold gets the strictest guard and no team tools, and writes no status under the member's name. The mod warns the member's head and tells the team top to ask you at once, with three choices:
| Choice | Effect, applied by the team top with member_claim |
|---|---|
| This is X | The roster takes the session's id, tab and name as member X. |
| New member under X's boss | The session joins as a worker under that boss, with its own role file. |
| Reject | The session stays on hold, off the team. |
A new member is saved as not yet, with its role file. It starts, fresh and briefed, on its boss's first team_message, like a member created at Launch. It joins a team that is already on the roster. Its name must be unused across the whole project, because a name is how teammates reach a session. With no role given, the role is "worker".
From the panel. Team actions → People → New member… asks for a name, a role, a boss, a model and an effort. The boss starts on the team's head; you can pick any member of the team. Press Add. No approval is needed: you are the one adding.
From a session, with member_add. With no boss given, the boss is the session that asks, when it is in that team; otherwise it is the team's head.
Who calls member_add | What happens |
|---|---|
| Your own session (not on the roster) | The member is added at once. |
| The team top | The top must ask you with AskUserQuestion first. The add counts only when that question was answered in the same turn. |
| A head or lead below the top | A request, saved as requests/<id>.json by member_add (members cannot write that folder by hand). The team top is told to ask you with AskUserQuestion, then applies your answer with member_add { request, approve }. Before it applies the request, it checks again that the member who asked is still on the roster and still allowed to ask. Nothing is added until then. |
| A worker | Refused. A worker asks its boss. |
| A session on hold | Refused, like every team tool. |
A launch no longer replaces a team that is already on the roster. Nothing is launched and nobody is removed. From team_launch, the answer offers two choices, and the session asks you which one you want. In the New team form, Launch is refused and two buttons appear: Add the new members to @team and Merge into @team.
| Choice | team_launch with | What happens |
|---|---|---|
| Add | ifExists: "add" | Every launched member is a new member of the existing team, saved as not yet and started on its boss's first message. A taken name gets a number (Worker-1 becomes Worker-1-2). |
| Merge | ifExists: "merge" | A launched member with the same name as a member of that team is that member, kept as it is. The rest join under their bosses. |
Eithe
hooks/register.tsx 4120 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Act, Bulk, Form, Member, Settings, Spawn, View } from '../types'
5import { headOf, joinLaunch, newMember, newProblem, purview, requestProblem } from './adding'
6import type { NewSpec } from './adding'
7import { AGENT_TOOL, grantChanges, type GrantMap, grantMap, grantsFrom, judge, judgeTeamFiles, namesTeamFile, NO_GRANTS, pathOf, recordAfterWrite, SHELL_TOOLS, WRITE_TOOLS } from './guard'
8import type { Grants } from './guard'
9import { isCleanConfirmation, onReceive, onSend, senderOf, shouldPoll } from './housekeeping'
10import { freshName, headWarning, holdNote, holdTool, identify, judgeHeld, nameTaken, relabel, roleNote, topAsk, topOf } from './identity'
11import type { Claim, Level } from './identity'
12import type { Sleep, Status, TeamSettings } from './status'
13import { countFromPs, linkFree, livenessOf, parseWinProcs, psProc, scratchDeletePlan, winProcScript } from './platform'
14import type { Liveness, Proc } from './platform'
15import { mergeRole, orgOf, pointer, roleText, WELCOME } from './roles'
16import { admit, awakeAge, chunk, cliOf, COUNT_EVERY_MS, heartbeatStale, isManaged, locationOf, MIN, modelArg, needsTabCheck, nextCheckInterval, noteTick, pathInWorktreeId, roleFile, shownModel, shownState, STALE_MS, startsAtCreate, statusFile, TEAM_SETTINGS0, taskLine, toClose, windowFor, worktreeHolds } from './status'
17import { afterTry, appliedAfter, applyOps, applySettings, diffOps, fileName, fromOldQueue, isAway, KEEP_MS, META0, metaAfter, olderThan, pendingNames, project, projectKey, projectTop, queueAction, rightsChanges, rosterOps, sameMachine, STRUCT, timeOf, topElsewhere } from './changes'
18import type { Change, Meta, QEntry, QReason } from './changes'
19import { CHECK as CHECK_W, cell, chartLabelInfo, columnPlan, EFFORT_SHORT, family, fit, headerLine, shorten, sideBySide } from './layout'
20
21// Orca's CLI is orca.exe on Windows and orca on macOS and Linux. The command is the plugin option "orcaCommand" (/config);
22// empty, the mod picks it from the platform, checks it starts, and writes it into the option once (see session.start).
23// PowerShell (transcript tails, leftover counts) is Windows only; macOS and Linux use tail and ps.
24const os = { windows: undefined as boolean | undefined }
25async function isWindows($: any): Promise<boolean> {
26 if (os.windows === undefined) os.windows = String((await $.env.get('OS').catch(() => undefined)) ?? '') === 'Windows_NT'
27 return os.windows
28}
29const cfg = { orcaCommand: '', version: '', versionRead: false }
30// the version shown in the panel, read once per load from this plugin's own manifest, so it always matches what is installed
31async function readVersion($: any) {
32 if (cfg.versionRead) return
33 cfg.versionRead = true
34 try {
35 cfg.version = String(JSON.parse(String(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`))).version ?? '')
36 } catch {
37 cfg.version = ''
38 }
39}
40const ORCA_KEY = 'team-orchestrator.orcaCommand'
41const orcaBin = async ($: any) => cfg.orcaCommand || ((await isWindows($)) ? 'orca.exe' : 'orca')
42/** '' when the command starts and answers --version, else why not, in a sentence. */
43async function orcaProblem($: any, command: string): Promise<string> {
44 const r = await $.process.run([command, '--version'], { timeoutMs: 20000 }).catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: String(err) }))
45 return r.exitCode === 0 ? '' : `"${command} --version" did not run (${String(r.stderr || r.stdout || `exit ${r.exitCode}`).trim().slice(0, 160)}).`
46}
47const TOOL = 'mcp__team-orchestrator__team_launch'
48const ADOPT = 'mcp__team-orchestrator__team_adopt'
49const REMOVE_TEAM = 'mcp__team-orchestrator__team_remove'
50const REMOVE_MEMBER = 'mcp__team-orchestrator__member_remove'
51const MOVE = 'mcp__team-orchestrator__member_move'
52const MESSAGE = 'mcp__team-orchestrator__team_message'
53const CLAIM = 'mcp__team-orchestrator__member_claim'
54const TAKE = 'mcp__team-orchestrator__team_take_top'
55const ADD = 'mcp__team-orchestrator__member_add'
56const MODELS = ['default', 'opus', 'sonnet', 'haiku', 'fable']
57const EFFORTS = ['default', 'low', 'medium', 'high', 'xhigh', 'max']
58
59const INTERVIEW =
60 'Interview me with AskUserQuestion to design a team of Claude Code sessions: ask about the team name, function, whether a CEO sits above several teams (and their names), how many levels, how many workers per head, model and effort per level, and any role specifics. Then call the mcp__team-orchestrator__team_launch tool with members ordered boss-first (boss "user" for the top member). Give every member a team; a CEO gets its own team name. Launching briefs every member automatically.'
61
62// The quick starts. Each fills the form, which stays editable, and Launch then briefs every session.
63// `sketch` uses the glyphs of the roster and the preview: ■ CEO, ● head, ◇ lead, ○ worker.
64const PRESETS = [
65 {
66 label: 'Squad', sketch: '●┬○○○', hint: 'head + 3 workers',
67 form: { team: 'Squad', ceo: '0', levels: '2', fan: '3', groups: '' },
68 },
69 {
70 label: 'All-Purpose Team', sketch: '■┬●●', hint: 'CEO + 2 heads, 4 workers each',
71 form: { team: 'All-Purpose-Team', ceo: '1', levels: '3', fan: '4', groups: 'Team 1, Team 2' },
72 },
73 {
74 label: 'Tech Team', sketch: '■┬●●●●', hint: 'CEO + 4 heads, 4 workers each',
75 form: { team: 'Tech-Team', ceo: '1', levels: '3', fan: '4', groups: 'Dev Team, UI Team, Test Team, Security Team' },
76 },
77]
78const sameShape = (f: Form, p: (typeof PRESETS)[number]) =>
79 f.ceo === p.form.ceo && f.fan === p.form.fan && (f.ceo === '1' ? f.groups === p.form.groups : f.levels === p.form.levels)
80
81// How a stored value reads on screen, and the colour it is drawn in. The stored values stay as `claude --model` takes them.
82const LABEL: Record<string, string> = {
83 default: 'Default', opus: 'Opus', sonnet: 'Sonnet', haiku: 'Haiku', fable: 'Fable',
84 low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra high', max: 'Max',
85}
86const SHADE: Record<string, string> = {
87 default: 'gray', opus: 'magenta', sonnet: 'cyan', haiku: 'green', fable: 'yellow',
88 // effort: one graded scale, low cool to max hot, in the form and the roster alike
89 low: '#6c8cff', medium: '#4ec9b0', high: '#e5c07b', xhigh: '#ff8c42', max: '#ff4d4d',
90}
91const nice = (v: string) => LABEL[v] ?? v
92// One colour per level for names in the roster and the org chart, the same in every team. None is the green or
93// yellow of the context bar; each reads on a dark background. Deeper levels share the last.
94const LEVEL_SHADE = ['#ff79c6', '#bd93f9', '#8be9fd', '#a0a8b8']
95const levelShade = (level: number) => LEVEL_SHADE[Math.min(Math.max(level, 1), LEVEL_SHADE.length) - 1] as string
96const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
97// When the person last changed a form field. A redraw while they type could drop a keystroke, so the form
98// animates only after 1.5 s of quiet.
99let typedAt = 0
100
101const FORM0: Form = {
102 team: 'team',
103 fn: '',
104 levels: '2',
105 fan: '3',
106 ceo: '0',
107 groups: '',
108 m1: 'default',
109 e1: 'default',
110 m2: 'default',
111 e2: 'default',
112 m3: 'default',
113 e3: 'default',
114}
115const form = atom({ plugin: 'team-orchestrator', key: 'form' } as const, FORM0)
116// State kept across a hot reload may predate a field, so defaults go under whatever was stored.
117const readForm = async ($: any): Promise<Form> => ({ ...FORM0, ...(await read($, form)) })
118const members = atom({ plugin: 'team-orchestrator', key: 'members' } as const, [] as Member[])
119const view = atom({ plugin: 'team-orchestrator', key: 'view' } as const, 'closed' as View)
120const teamName = atom({ plugin: 'team-orchestrator', key: 'teamName' } as const, '')
121const note = atom({ plugin: 'team-orchestrator', key: 'note' } as const, '')
122// frame counter for the animations; only advanced while something animated is on screen (see session.start)
123const frame = atom({ plugin: 'team-orchestrator', key: 'frame' } as const, 0)
124// which model/effort dropdown is open in the form ('' none, 'm2' = level 2's model, 'e1' = level 1's effort)
125const menu = atom({ plugin: 'team-orchestrator', key: 'menu' } as const, '')
126// The roster's look. Plugin state: it outlives a reload and a refresh, not a new session. Defaults are the old look.
127const SETTINGS0: Settings = { layout: 'stacked', chart: true, hide: [] }
128const settings = atom({ plugin: 'team-orchestrator', key: 'settings' } as const, SETTINGS0)
129const readSettings = async ($: any): Promise<Settings> => ({ ...SETTINGS0, ...(await read($, settings)) })
130const COLS = ['STATUS', 'CONTEXT', 'MODEL', 'EFFORT', 'BRIEF'] as const
131// a card's border and padding, around the cells columnPlan lays out
132const FRAME = 4
133// Team cards per row: side by side only where two fit, so a narrow terminal keeps the stacked look.
134// side by side and dock right: as many cards as fit at the narrower tiers (each card then picks the widest tier its
135// width allows); stacked keeps one full-width card per row
136const cardsPerRow = (s: Settings, cols: number, _cardW: number, cards: number) =>
137 s.layout === 'columns' || s.layout === 'dock' ? sideBySide(cols, cards, FRAME) : 1
138const ACT0: Act = { menu: '', kind: 'none', to: '', key: '', draft: '', boss: '', handle: '', role: '', tabs: [], msg: '', name: '', model: 'default', effort: 'default' }
139const act = atom({ plugin: 'team-orchestrator', key: 'act' } as const, ACT0)
140const readAct = async ($: any): Promise<Act> => ({ ...ACT0, ...(await read($, act)) })
141// the side pane the panel moves to under the dock layout (the panes of earlier versions had other ids)
142const DOCK = 'team-dock'
143const SPAWN0: Spawn = { names: [], batch: 0, of: 0 }
144const spawn = atom({ plugin: 'team-orchestrator', key: 'spawn' } as const, SPAWN0)
145const bulk = atom({ plugin: 'team-orchestrator', key: 'bulk' } as const, {
146 prefix: '',
147 base: '',
148 numbering: 'none',
149 model: 'keep',
150 effort: 'keep',
151 msg: '',
152} as Bulk)
153
154type Spec = { name: string; role: string; level: number; boss: string; team?: string; model?: string; effort?: string; short?: string }
155
156const clean = (s: string) => s.replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 40)
157
158const uuid = () =>
159 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
160 const r = Math.floor(Math.random() * 16)
161 return (c === 'x' ? r : (r % 4) + 8).toString(16)
162 })
163
164const levelName = (l: number, f: Form) =>
165 f.ceo === '1' ? (['CEO', 'Heads', 'Workers'][l - 1] ?? 'Workers') : l === 1 ? 'Head' : l === Number(f.levels) ? 'Workers' : 'Leads'
166
167const groupsOf = (f: Form): string[] => f.groups.split(',').map(g => clean(g.trim())).filter(Boolean)
168
169// One team: a head, then `fan` reports per manager down `levels` tiers (1 = head only, 3 = head/leads/workers).
170// With a CEO: the CEO, one head per named team, and `fan` workers under each head.
171const plan = (f: Form): Spec[] => {
172 const lv = (l: number) => ({
173 model: (f as any)[`m${l}`] as string,
174 effort: (f as any)[`e${l}`] as string,
175 })
176 const goal = f.fn || 'unspecified'
177 const fan = Number(f.fan)
178 if (f.ceo === '1') {
179 const org = clean(f.team) || 'Org'
180 const ceo: Spec = {
181 name: 'CEO', team: org, level: 1, boss: 'user',
182 role: `CEO: owns the goal "${goal}", splits it between the team heads, reviews, reports to the user`, ...lv(1),
183 }
184 const out: Spec[] = [ceo]
185 for (const g of groupsOf(f)) {
186 const head: Spec = {
187 name: `${g}-Head`, team: g, level: 2, boss: ceo.name,
188 role: `Head of team ${g}: takes goals from the CEO, splits them among its workers, reviews, reports to the CEO`, ...lv(2),
189 }
190 out.push(head)
191 for (let i = 1; i <= fan; i++) {
192 out.push({
193 name: `${g}-Worker-${i}`, team: g, level: 3, boss: head.name,
194 role: `Worker: executes tasks from ${head.name} and reports back`, ...lv(3),
195 })
196 }
197 }
198 return out
199 }
200 const levels = Number(f.levels)
201 const team = clean(f.team) || 'team'
202 const head: Spec = {
203 name: 'Head', team, level: 1, boss: 'user',
204 role: `Team head: owns the goal "${goal}", splits it, delegates, reviews, reports to the user`, ...lv(1),
205 }
206 const out: Spec[] = [head]
207 let tier: Spec[] = [head]
208 for (let l = 2; l <= levels; l++) {
209 const next: Spec[] = []
210 tier.forEach((b, bi) => {
211 for (let i = 1; i <= fan; i++) {
212 const isLast = l === levels
213 const n = levels === 2 ? `${i}` : `${bi + 1}-${i}`
214 next.push({
215 name: isLast ? `Worker-${n}` : `Lead-${levels === 2 ? i : `${bi + 1}-${i}`}`,
216 team,
217 role: isLast
218 ? `Worker: executes tasks from ${b.name} and reports back`
219 : `Lead: splits work from ${b.name} among its own reports and reviews them`,
220 level: l,
221 boss: b.name,
222 ...lv(l),
223 })
224 }
225 })
226 out.push(...next)
227 tier = next
228 }
229 return out
230}
231
232// ASCII org chart: depth-first from the head, with guide lines.
233const treeLines = (list: { name: string; boss: string }[]): { name: string; prefix: string }[] => {
234 const kids = new Map<string, string[]>()
235 for (const m of list) kids.set(m.boss, [...(kids.get(m.boss) ?? []), m.name])
236 const out: { name: string; prefix: string }[] = []
237 const walk = (name: string, guide: string, isLast: boolean, isRoot: boolean) => {
238 out.push({ name, prefix: isRoot ? '' : guide + (isLast ? '└─ ' : '├─ ') })
239 const c = kids.get(name) ?? []
240 c.forEach((k, i) => walk(k, isRoot ? '' : guide + (isLast ? ' ' : '│ '), i === c.length - 1, false))
241 }
242 const names = new Set(list.map(m => m.name))
243 list.filter(m => !names.has(m.boss)).forEach(m => walk(m.name, '', true, true))
244 return out
245}
246
247// state -> [glyph, label, color]
248const look = (s: string): [string, string, string] =>
249 s === 'working' ? ['◐', 'working', 'yellow']
250 : s === 'idle' ? ['●', 'idle', 'green']
251 : s === 'asking' ? ['◆', 'asking', 'magenta']
252 : s === 'starting' ? ['◌', 'starting', 'cyan']
253 : s === 'offline' ? ['○', 'offline', 'gray']
254 : s === 'failed' ? ['✗', 'failed', 'red']
255 : s === 'closed' ? ['–', 'closed', 'gray']
256 : s === 'unstarted' ? ['·', 'not yet', 'gray']
257 : s === 'queued' ? ['○', 'queued', 'gray']
258 : s === 'away' ? ['◌', 'away', 'gray']
259 : s === 'unmanaged' ? ['◇', 'external', 'gray']
260 : ['?', s.slice(0, 8), 'magenta']
261
262// `cells` wide bar and the percent on one line; 0 cells is the percent alone
263const bar = (pct: number, cells = 8): [string, string] => {
264 const frame = (inner: string) => (cells > 0 ? `[${inner}] ` : '')
265 if (pct < 0) return [`${frame('·'.repeat(cells))} --`, 'gray']
266 const f = Math.min(cells, Math.round((pct * cells) / 100))
267 return [`${frame(`${'█'.repeat(f)}${'░'.repeat(cells - f)}`)}${String(pct).padStart(3)}%`, pct < 50 ? 'green' : pct < 80 ? 'yellow' : 'red']
268}
269
270const handleOf = (json: string): string => json.match(/term_[0-9a-f-]+/)?.[0] ?? ''
271
272async function orca($: any, ...args: string[]) {
273 const r = await $.process.run([await orcaBin($), ...args, '--json'], { timeoutMs: 120000 }).catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: String(err) }))
274 return r.exitCode === 0
275 ? { ok: true, out: r.stdout as string }
276 : { ok: false, out: String(r.stderr || r.stdout) }
277}
278
279const flags = (model?: string, effort?: string) => {
280 const m = modelArg(model ?? '')
281 // "[1m]" is a pattern to zsh: a name carrying it goes in double quotes (cmd.exe and bash take those too)
282 return `${m ? ` --model ${/[[\]]/.test(m) ? `"${m}"` : m}` : ''}${effort && effort !== 'default' && effort !== 'keep' ? ` --effort ${effort}` : ''}`
283}
284
285
286// State kept across a hot reload may predate the team field: such members belong to the one team the old version knew.
287const readMembers = async ($: any): Promise<Member[]> => {
288 const fallback = (await read($, teamName)) || 'team'
289 return (await read($, members)).map(m => ({ ...m, team: m.team || fallback }))
290}
291
292// Every session runs its own copy of this mod, so the teams live in one folder per project, inside the project:
293// <project root>/.claude/team-orchestrator/. roster.json holds the structure (teams, bosses, roles) and, for each
294// member, the status file it writes (statusFile); settings.json holds the team-wide worker settings; meta.json the schema
295// stamp; changes/ the changes other sessions made, waiting for the team top; queue/ the messages waiting for room.
296// Another project has its own folder. Live status is kept on each machine, outside the project (see readStatus).
297const rootOf = async ($: any) => String(await $.session.root()).replace(/[\/]+$/, '')
298const teamDir = async ($: any) => `${await rootOf($)}/.claude/team-orchestrator`
299const teamFile = async ($: any) => `${await teamDir($)}/roster.json`
300// the single file of versions before 0.5.0, migrated into the folder on first read
301const oldTeamFile = async ($: any) => `${await rootOf($)}/.claude/team-orchestrator.json`
302
303// An Orca workspace as Orca knows it now: whether it still exists, and its folder ('' when Orca gives none).
304async function worktreeInfo($: any, id: string): Promise<{ exists: boolean; path: string }> {
305 const r = await orca($, 'worktree', 'show', '--worktree', `id:${id}`)
306 if (!r.ok) return { exists: false, path: '' }
307 try {
308 return { exists: true, path: String(JSON.parse(r.out).result?.worktree?.path ?? '') || pathInWorktreeId(id) }
309 } catch {
310 return { exists: true, path: pathInWorktreeId(id) }
311 }
312}
313
314// The Orca workspace (worktree id) this session runs in (#68). The tab's own ORCA_WORKTREE_ID counts only while that
315// workspace's folder contains the project root: after a /cd or a moved project it names the old one. Otherwise
316// `orca worktree current`, run in the session's own folder, answers.
317async function currentWorktree($: any): Promise<string> {
318 // Orca names the workspace of the tab this session runs in; asking Orca by folder picks the wrong one when two
319 // workspaces share a folder
320 const own = String((await $.env.get('ORCA_WORKTREE_ID').catch(() => undefined)) ?? '').trim()
321 if (own) {
322 const root = await rootOf($)
323 const inId = pathInWorktreeId(own)
324 if (worktreeHolds(inId !== '' ? inId : (await worktreeInfo($, own)).path, root)) return own
325 }
326 const cur = await orca($, 'worktree', 'current')
327 return cur.ok ? (cur.out.match(/"worktree":\s*\{\s*"id":\s*"((?:[^"\\]|\\.)*)"/)?.[1] ?? '').replace(/\\\\/g, '\\') : ''
328}
329
330// The workspace a member starts in (#68): its saved one while Orca still has it and it holds the project root; else the
331// one this session finds, which the caller records on the member (from a session that is not the team top, through a
332// change file, like every roster change). '' when neither is known.
333async function memberWorktree($: any, m: Member): Promise<string> {
334 if (m.worktree) {
335 const i = await worktreeInfo($, m.worktree)
336 if (i.exists && worktreeHolds(i.path, await rootOf($))) return m.worktree
337 }
338 return (await currentWorktree($)) || m.worktree || ''
339}
340
341// Before 0.5.0 the roster was one file, .claude/team-orchestrator.json. Its content moves into the folder once (a copy
342// stays as roster.json.bak) and the old file is left as a pointer, so an old copy of the mod no longer reads it as a roster.
343async function migrate($: any) {
344 const [from, to] = [await oldTeamFile($), await teamFile($)]
345 if ((await $.fs.exists(to)) || !(await $.fs.exists(from))) return
346 const text = String(await $.fs.read(from))
347 try {
348 if (!Array.isArray(JSON.parse(text))) return
349 } catch {
350 return
351 }
352 await $.fs.write(to, text)
353 await $.fs.write(`${await teamDir($)}/roster.json.bak`, text)
354 await $.fs.write(from, JSON.stringify({ movedTo: '.claude/team-orchestrator/roster.json' }))
355}
356
357// The file sits inside the repo, so the first write of each load lists it in the repo's info/exclude: git then
358// never offers it to a commit. git answers for a worktree (.git is a file there) and a subfolder; outside a repo it fails.
359const excluded = new Set<string>()
360async function exclude($: any) {
361 const root = String(await $.session.root())
362 if (excluded.has(root)) return
363 excluded.add(root)
364 const r = await $.process.run(['git', '-C', root, 'rev-parse', '--show-prefix', '--path-format=absolute', '--git-path', 'info/exclude']).catch(() => undefined)
365 if (r?.exitCode !== 0) return
366 const [prefix, path] = String(r.stdout).split(/\r?\n/)
367 if (!path) return
368 const line = `${prefix ?? ''}.claude/team-orchestrator/`
369 const before = (await $.fs.exists(path)) ? String(await $.fs.read(path)) : ''
370 if (before.split(/\r?\n/).some(l => l.trim().replace(/^\//, '') === line)) return
371 await $.fs.write(path, `${before}${before === '' || before.endsWith('\n') ? '' : '\n'}${line}\n`)
372}
373
374// ── One writer (0.5.12, #59) and machines (#64), see changes.ts ──────────────────────────────────────────────
375// Only the team top's session, on the top's machine, writes roster.json and settings.json. Every other session writes
376// a change file (changes/<ms>-<rand>.json) with the fields it changed, and applies the change to its own view at once;
377// every session reads the roster as the file plus the change files not yet applied, so all of them see the same team.
378// The top folds the change files into the file on its next refresh (30 s at most) and lists them in changes/applied.json
379// (the mod cannot delete a file). Until a team has a running top on this machine, a session that is not on the roster
380// (the person's own) writes in its place; the first write of a new team is always direct.
381
382// This machine's name, for each member's home machine and the top machine; '' when the platform gives none.
383async function machineOf($: any): Promise<string> {
384 const none = () => undefined
385 return String((await $.env.get('COMPUTERNAME').catch(none)) || (await $.env.get('HOSTNAME').catch(none)) || '').trim()
386}
387
388const changesDir = async ($: any) => `${await teamDir($)}/changes`
389const metaFile = async ($: any) => `${await teamDir($)}/meta.json`
390const rand = () => Math.random().toString(36).slice(2, 10).padEnd(8, '0')
391
392async function readJson($: any, path: string): Promise<any> {
393 if (!(await $.fs.exists(path).catch(() => false))) return undefined
394 try {
395 return JSON.parse(String(await $.fs.read(path)))
396 } catch {
397 return undefined
398 }
399}
400
401async function readMeta($: any): Promise<Meta> {
402 const m = await readJson($, await metaFile($))
403 return m && typeof m === 'object' && !Array.isArray(m) ? { ...META0, ...m } : META0
404}
405
406// A change file never changes once written, so each is read once per load.
407const changeMemo = new Map<string, Change>()
408type Pending = { name: string; change: Change }
409async function readApplied($: any): Promise<string[]> {
410 const v = await readJson($, `${await changesDir($)}/applied.json`)
411 return Array.isArray(v?.names) ? v.names.filter((n: unknown): n is string => typeof n === 'string') : []
412}
413
414/** The change files not yet applied, oldest first. */
415async function pendingChanges($: any): Promise<Pending[]> {
416 const dir = await changesDir($)
417 const now = Date.now()
418 const names = ((await $.fs.list(dir).catch(() => [])) as any[])
419 .filter(f => f.kind === 'file')
420 .map(f => String(f.name))
421 .filter(n => now - timeOf(n) <= KEEP_MS)
422 if (names.length === 0) return []
423 const out: Pending[] = []
424 for (const n of pendingNames(names, new Set(await readApplied($)), now)) {
425 let c = changeMemo.get(n)
426 if (!c) {
427 const v = await readJson($, `${dir}/${n}`)
428 // one being written (or synced) right now is read next time
429 if (!v || (v.kind !== 'roster' && v.kind !== 'settings')) continue
430 c = v as Change
431 changeMemo.set(n, c)
432 }
433 out.push({ name: n, change: c })
434 }
435 return out
436}
437
438async function markApplied($: any, names: string[]) {
439 if (names.length === 0) return
440 await $.fs.write(`${await changesDir($)}/applied.json`, JSON.stringify({ names: appliedAfter(await readApplied($), names, Date.now()) }, null, 1))
441}
442
443// The roster as this session last read it (the file plus the pending changes), and which change files that read
444// included: a non-writer's change file holds only what differs from this base, field by field.
445const seenRoster = { base: undefined as Partial<Member>[] | undefined, overlay: new Set<string>() }
446
447/** The roster file's rows with the pending change files applied; undefined when there is no roster file. */
448async function effective($: any): Promise<{ rows: Partial<Member>[]; applied: string[] } | undefined> {
449 const file = await teamFile($)
450 if (!(await $.fs.exists(file))) return undefined
451 let saved: unknown
452 try {
453 saved = JSON.parse(String(await $.fs.read(file)))
454 } catch {
455 return undefined
456 }
457 if (!Array.isArray(saved)) return undefined
458 const roster = (await pendingChanges($)).filter(c => c.change.kind === 'roster')
459 return { rows: roster.length > 0 ? applyOps(saved as Partial<Member>[], rosterOps(roster)) : (saved as Partial<Member>[]), applied: roster.map(c => c.name) }
460}
461
462/**
463 * Who writes the team files from this session:
464 * - top: the confirmed team top on the top's machine; standin: a session not on the roster while no top runs on this
465 * machine; boot: there is no roster file yet. These three write the files.
466 * - member: any other session; old: this mod is older than the one that last wrote the roster; away: the top machine
467 * is another PC. These write change files.
468 */
469type Role = 'top' | 'standin' | 'boot' | 'member' | 'old' | 'away'
470type Writer = { role: Role; meta: Meta; here: string; me: string }
471const writes = (r: Writer) => r.role === 'top' || r.role === 'standin' || r.role === 'boot'
472// what this session has been told (once each); reset at each load
473const told = { old: false, away: '', asked: false, declined: false }
474// an AskUserQuestion was answered in the turn that is running: team_take_top needs the user's answer first
475const turnAsk = { answered: false }
476
477async function writerRole($: any, who?: Who): Promise<Writer> {
478 await readVersion($)
479 const meta = await readMeta($)
480 const here = await machineOf($)
481 const base = { meta, here, me: who?.me?.name ?? '' }
482 if (olderThan(cfg.version, meta.writtenBy)) {
483 if (!told.old) {
484 told.old = true
485 await $.ui.toast(
486 `Team Orchestrator: this team's files were written by team-orchestrator ${meta.writtenBy}, newer than this session's ${cfg.version}. ` +
487 'Update team-orchestrator and /reload-plugins. Until then this session writes no team file; its roster changes wait for the team top.',
488 )
489 }
490 return { ...base, role: 'old' }
491 }
492 if (!(await $.fs.exists(await teamFile($)))) return { ...base, role: 'boot' }
493 const list = await readMembers($)
494 const w = who ?? (await identity($, list))
495 const me = w.me?.name ?? ''
496 const top = projectTop(list)
497 const elsewhere = topElsewhere(meta, here)
498 if (w.level === 'full' && w.me && top && keyOf(w.me) === keyOf(top)) {
499 if (!elsewhere) return { ...base, me, role: 'top' }
500 await topAway($, meta, here, true)
501 return { ...base, me, role: 'away' }
502 }
503 if (w.level === 'none') {
504 if (elsewhere) {
505 await topAway($, meta, here, false)
506 return { ...base, me, role: 'away' }
507 }
508 if (!(await topLive($, list, here))) return { ...base, me, role: 'standin' }
509 }
510 return { ...base, me, role: 'member' }
511}
512
513// The team top is running: its status on this machine is fresh, or this machine's registry lists its session. A top
514// whose home is another PC counts as running: this PC does not stand in for it.
515async function topLive($: any, list: Member[], here: string): Promise<boolean> {
516 const top = projectTop(list)
517 if (!top) return false
518 if (isAway(top, here)) return true
519 const s = await readStatus($, top.name)
520 if (s && s.state !== 'closed' && Date.now() - s.heartbeat < STALE_MS) return true
521 return !!top.sessionId && (await registry($)).some(r => r.sessionId === top.sessionId)
522}
523
524const takeAsk = (there: string, here: string) =>
525 `TEAM ORCHESTRATOR, act now: the team top is recorded on ${there}, not on this PC (${here}). Until that changes this session ` +
526 'writes no team file, closes no idle worker and delivers no queued message. Ask the user AT ONCE with AskUserQuestion whether this PC ' +
527 `takes over as the team top, with two options: (1) "Take over on ${here}": the roster records this PC as the top's, and ${there} becomes read-only; ` +
528 `(2) "Keep ${there}". Then apply the answer with team_take_top { take: true | false }.`
529
530// The top machine is another PC: said once per session, and a top session's model is asked to check with the user.
531async function topAway($: any, meta: Meta, here: string, isTop: boolean) {
532 if (told.away !== meta.topMachine) {
533 told.away = meta.topMachine
534 await $.ui.toast(
535 `Team Orchestrator: the team top runs on ${meta.topMachine}, not this PC (${here}). Here the team files are read-only: ` +
536 `changes wait in changes/ for ${meta.topMachine}, and auto-close and the queue run there.` +
537 (isTop ? ' You are asked whether this PC takes over.' : ' team_take_top moves the top here, once you say yes.'),
538 )
539 }
540 if (isTop && !told.asked && !told.declined) {
541 told.asked = true
542 addNote(String(await $.session.id().catch(() => '')), takeAsk(meta.topMachine, here))
543 }
544}
545
546// One writer at a time inside this session (a refresh and a Settings press may overlap).
547const lock = { chain: Promise.resolve() as Promise<unknown> }
548function serial<T>(fn: () => Promise<T>): Promise<T> {
549 const run = lock.chain.then(fn, fn)
550 lock.chain = run.catch(() => undefined)
551 return run
552}
553
554const FRESH = { state: 'idle', ctx: -1, model: '', effort: '', sel: false, note: '', briefed: false, noted: false }
555const rowText = (list: Partial<Member>[]) =>
556 JSON.stringify(list.map(m => Object.fromEntries(STRUCT.map(k => [k, k === 'statusFile' ? statusFile(String(m.name ?? '')) : (m as any)[k]]))), null, 1)
557
558// Persist this session's roster: written by the writer, else as a change file. Role files follow either way.
559// `who` is given by identity itself, which must not wait for its own answer.
560async function share($: any, who?: Who) {
561 await migrate($)
562 const r = await writerRole($, who)
563 await serial(() => (writes(r) ? writeRoster($, r) : writeChange($, r)))
564 if (r.role !== 'old') await writeRoles($, await readMembers($))
565}
566
567// The writer: change files that arrived since this session last read the roster are applied on top of its own view,
568// in time order, field by field; then the file is written and every pending change is listed as applied.
569async function writeRoster($: any, r: Writer) {
570 const pending = await pendingChanges($)
571 const roster = pending.filter(c => c.change.kind === 'roster')
572 const fresh = roster.filter(c => !seenRoster.overlay.has(c.name))
573 let list = await readMembers($)
574 if (fresh.length > 0) {
575 list = applyOps(list, rosterOps(fresh)).map(m => ({ ...FRESH, ...m }) as Member)
576 await update($, members, () => list)
577 }
578 const text = rowText(list)
579 const file = await teamFile($)
580 const before = (await $.fs.exists(file)) ? String(await $.fs.read(file)) : ''
581 if (before !== text) {
582 await exclude($)
583 const prev = await readGrantRecord($, file)
584 await $.fs.write(file, text)
585 await writeGrantRecord($, file, recordAfterWrite(prev, grantMap(rowsOf(before)), grantMap(list)))
586 }
587 // the stamp: the newest mod version that wrote, and the top's machine (moved only by team_take_top once set)
588 if (r.role === 'top') {
589 const next = metaAfter(r.meta, cfg.version, r.here)
590 const was = { schemaVersion: r.meta.schemaVersion, writtenBy: r.meta.writtenBy, topMachine: r.meta.topMachine }
591 if (JSON.stringify(next) !== JSON.stringify(was) || !(await $.fs.exists(await metaFile($)))) await $.fs.write(await metaFile($), JSON.stringify(next, null, 1))
592 }
593 const settingsChanges = pending.filter(c => c.change.kind === 'settings')
594 if (settingsChanges.length > 0) await writeSettingsFile($, applySettings(await readSettingsFile($), settingsChanges))
595 await markApplied($, pending.map(c => c.name))
596 const rights = rightsChanges(roster)
597 if (rights.length > 0) await $.ui.toast(`Team Orchestrator: applied a change of rights made in another session: ${rights.join('; ')}.`)
598 seenRoster.base = list.map(project)
599 seenRoster.overlay = new Set()
600}
601
602async function writeChangeFile($: any, r: Writer, body: { kind: 'roster'; ops: any[] } | { kind: 'settings'; patch: Partial<TeamSettings> }) {
603 const at = Date.now()
604 const name = fileName(at, rand())
605 const sid = String(await $.session.id().catch(() => ''))
606 const c = { v: 1, ...body, at, by: { session: sid, member: r.me, machine: r.here, version: cfg.version } } as Change
607 await $.fs.write(`${await changesDir($)}/${name}`, JSON.stringify(c, null, 1))
608 changeMemo.set(name, c)
609 // this session's view has it already: as the writer later, it does not apply it again over newer edits
610 seenRoster.overlay.add(name)
611}
612
613// Everyone else: what this session changed since it last read the roster, as one change file. Its own view keeps the
614// change (it was made there first), and the next read shows the same, since every read applies pending change files.
615async function writeChange($: any, r: Writer) {
616 const list = await readMembers($)
617 const base = seenRoster.base ?? (await effective($))?.rows ?? []
618 const ops = diffOps(base, list)
619 if (ops.length === 0) return
620 await writeChangeFile($, r, { kind: 'roster', ops })
621 seenRoster.base = list.map(project)
622}
623
624// ── Grants changed outside the mod (#58) ──
625// What the mod last wrote for each member's Allow writes and Allow subagents, per roster file: in $.store, which every
626// session of this machine shares, with this session's own copy as the fallback. The team top's refresh compares the
627// file with it (checkGrants). Reported only, never reverted.
628const grantMemo = new Map<string, GrantMap>()
629const grantKey = (file: string) => `grants:${file.replace(/\\/g, '/').toLowerCase()}`
630async function readGrantRecord($: any, file: string): Promise<GrantMap | undefined> {
631 const v = await $.store.get(grantKey(file)).catch(() => undefined)
632 return v && typeof v === 'object' ? (v as GrantMap) : grantMemo.get(grantKey(file))
633}
634async function writeGrantRecord($: any, file: string, g: GrantMap) {
635 grantMemo.set(grantKey(file), g)
636 await $.store.set(grantKey(file), g).catch(() => undefined)
637}
638const rowsOf = (text: string): Partial<Member>[] => {
639 try {
640 const v = JSON.parse(text)
641 return Array.isArray(v) ? v : []
642 } catch {
643 return []
644 }
645}
646
647// A difference is reported once it shows on two refreshes in a row (a write by another session's mod, caught between
648// its file and its record, is gone by the next one), and each difference once.
649const tamper = { last: '', told: new Set<string>() }
650const TAMPER_NOTE = 'roster.json changed outside the mod'
651async function checkGrants($: any) {
652 const file = await teamFile($)
653 if (!(await $.fs.exists(file))) return
654 const now = grantMap(rowsOf(String(await $.fs.read(file))))
655 const rec = await readGrantRecord($, file)
656 if (!rec) return void (await writeGrantRecord($, file, now))
657 const diff = grantChanges(rec, now)
658 const sig = JSON.stringify(diff)
659 const twice = sig === tamper.last
660 tamper.last = sig
661 if (diff.length === 0 || !twice || tamper.told.has(sig)) return
662 tamper.told.add(sig)
663 const say = diff.map(d => `${d.name}: ${d.changes.join(', ')}`).join('; ')
664 await $.ui.toast(`Team Orchestrator: ${TAMPER_NOTE} (${say}). Nothing was reverted; check Settings → Allow writes / Allow subagents.`)
665 await update($, members, old =>
666 old.map(m => {
667 const d = diff.find(x => x.key === keyOf(m))
668 if (!d) return m
669 const kept = m.note.split(', ').filter(p => p !== '' && !p.startsWith(TAMPER_NOTE))
670 return { ...m, note: [...kept, `${TAMPER_NOTE}: ${d.changes.join(', ')}`].join(', ') }
671 }),
672 )
673}
674
675// Each member's role file (roles.ts): the generated part follows the roster, the person's notes below the marker stay.
676// A file is read and written only when its generated part changed since this session last wrote or checked it.
677const roleSeen = new Map<string, string>()
678async function writeRoles($: any, list: Member[]) {
679 const dir = await teamDir($)
680 for (const m of list) {
681 const generated = roleText(m, orgOf(list, m.team))
682 const path = `${dir}/${roleFile(m.name)}`
683 if (roleSeen.get(path) === generated) continue
684 const before = (await $.fs.exists(path)) ? String(await $.fs.read(path)) : undefined
685 const after = mergeRole(before, generated)
686 if (after !== before) await $.fs.write(path, after)
687 roleSeen.set(path, generated)
688 }
689}
690
691// The command that starts a member: its role pointer in the system prompt, and an optional first prompt. A shell types
692// it (cmd.exe on Windows, bash or zsh elsewhere), so the texts go in double quotes and carry nothing a shell would
693// expand (pointer() and WELCOME see to that).
694const startCmd = (m: Member, list: Member[], sessionId: string, resume: boolean, name = m.name, model = m.model, effort = m.effort, first = '') =>
695 `claude ${resume ? `--resume ${sessionId}` : `--session-id ${sessionId}`} --name ${name}${flags(model, effort)}` +
696 ` --append-system-prompt "${pointer({ ...m, name }, list)}"${first ? ` "${first}"` : ''}`
697
698// The roster as every session sees it: the file plus the change files not yet applied (see share).
699async function pull($: any) {
700 await migrate($)
701 const eff = await effective($)
702 // an empty list is a real state (the last team was removed), not a missing file
703 if (!eff) return
704 const saved = eff.rows
705 seenRoster.base = saved.map(project)
706 seenRoster.overlay = new Set(eff.applied)
707 const fresh = { state: 'idle', ctx: -1, model: '', effort: '', sel: false, note: '' }
708 await update($, members, old =>
709 saved.map(m => {
710 const o = old.find(x => x.name === m.name && (x.team || m.team) === m.team)
711 // a member that never started shows so in every session, whatever state this one last saw
712 return { ...fresh, ...(o ?? {}), ...m, sel: o?.sel ?? false, ...(m.pending ? { state: 'unstarted' } : {}) } as Member
713 }),
714 )
715}
716
717// The one-time briefing: structure, reporting line and communication rules, sent once per session.
718const briefText = (m: Member, list: Member[], team: string): string => {
719 const kids = list.filter(x => x.boss === m.name)
720 const isManager = kids.length > 0
721 const peers = list.filter(x => x.name !== m.name && list.some(k => k.boss === x.name))
722 const addr = (x: Member) => `"${x.address || x.name}"`
723 const roster = list.map(x => `${x.address || x.name} (${x.level === 1 ? 'head' : list.some(k => k.boss === x.name) ? 'lead' : 'worker'}, reports to ${x.boss})`).join('; ')
724 const rules = isManager
725 ? `YOUR JOB: you ORCHESTRATE. You decide, plan and instruct only; you do NOT execute tasks yourself, you delegate execution to your direct reports and review what they send back. ` +
726 `Your direct reports: ${kids.map(addr).join(', ')}. ` +
727 (m.boss === 'user' ? 'You report to the user. ' : `Your boss: ${m.boss}. `) +
728 `COMMUNICATION: you may message your boss, your direct reports, and any other head or lead in any department` +
729 (peers.length ? ` (${peers.map(addr).join(', ')})` : '') +
730 `. Do not bypass a lead to instruct someone else's worker. ` +
731 `Message your direct reports with the team_message tool (mcp__team-orchestrator__team_message, { to: "<name>", message: "..." }), not SendMessage: a report may not have started yet or may have been closed while idle, and team_message starts it, briefs it and then delivers.`
732 : `YOUR JOB: you EXECUTE the tasks your direct boss gives you and report results back. Your direct boss (one level up): ${m.boss}. ` +
733 `COMMUNICATION: talk ONLY to your direct boss. Do NOT message your boss's boss, other leads, or other workers. ` +
734 `Worker-to-worker contact is forbidden unless your boss explicitly names that worker to you in a message.`
735 return (
736 `TEAM BRIEFING (one-time, from the Team Orchestrator). You are ${m.name} in team "${m.team}". Role: ${m.role}. ${rules} ` +
737 `Team structure: ${roster}. ` +
738 `TEAM FILES: the team lives in .claude/team-orchestrator/ in the project. roster.json is the structure (teams, bosses, roles) and settings.json the team's worker settings; only the team top's session writes them, and a change made in any other session waits in changes/ until the top applies it. Never edit roster.json, settings.json, meta.json or changes/ by hand. Each member's live status (state, task, model, context, last "clean") is written by the Team Orchestrator for its own session, on its own PC under ~/.claude/team-orchestrator/; never edit another member's status file. ` +
739 `To message a teammate use the SendMessage tool (Claude Code's native agent messaging), e.g. SendMessage({ to: "<their name>", message: "..." }), where the name is the quoted name shown above or in the structure list. If SendMessage says the name is ambiguous or unknown and the person is on the team, use team_message with the plain name instead (it addresses that member's own session by its id: never a same-named session of another project, never a Remote Control copy). Do NOT use orca terminal send or the terminal for messages to teammates. ` +
740 `Now reply with exactly "Noted" plus one short line restating your role and reporting line, then wait for instructions.`
741 )
742}
743
744async function briefTeam($: any, team: string, only?: Set<string>) {
745 const all: Member[] = await readMembers($)
746 const mine = all.filter(m => m.team === team)
747 // the briefing lists the team and every boss above it, so a head learns who its CEO is
748 const list = orgOf(all, team)
749 const sent = new Set<string>()
750 // a member on another PC has its tab there: its handle means nothing here
751 const here = await machineOf($)
752 await Promise.all(
753 mine
754 .filter(m => m.handle && !isAway(m, here) && isManaged(m) && (!only || only.has(keyOf(m))))
755 .map(async m => {
756 const r = await orca($, 'terminal', 'send', '--terminal', m.handle, '--text', briefText(m, list, team), '--enter')
757 if (r.ok) sent.add(m.name)
758 }),
759 )
760 await update($, members, old => old.map(m => ((m.team || 'team') === team && sent.has(m.name) ? { ...m, briefed: true, noted: false } : m)))
761 await share($)
762}
763
764// ── Transcripts: <config dir>/projects/<project>/<session id>.jsonl ──────────────────────────────────────────
765// Only these things are taken from a transcript: its last customTitle, its last requestedModel, and the last assistant
766// message's model, usage (as a context percent), effort and cwd. Nothing else is kept or shown: a transcript can hold
767// secrets.
768type Transcript = { id: string; path: string; mtimeMs: number }
769/** requested: the last model asked for, as typed ('' unknown); used: the tokens the last answer was given over */
770type Stats = { model: string; requested: string; used: number; ctx: number; effort: string; cwd: string }
771
772// Every transcript, newest first.
773async function transcripts($: any): Promise<Transcript[]> {
774 const none = () => undefined
775 const home = (await $.env.get('USERPROFILE').catch(none)) || (await $.env.get('HOME').catch(none))
776 const dir = (await $.env.get('CLAUDE_CONFIG_DIR').catch(none)) || (home ? `${home}/.claude` : '')
777 if (!dir) return []
778 const root = `${dir}/projects`
779 const projects = ((await $.fs.list(root).catch(() => [])) as any[]).filter(p => p.kind === 'dir')
780 const lists = await Promise.all(projects.map(async p => ((await $.fs.list(`${root}/${p.name}`).catch(() => [])) as any[]).map(f => ({ ...f, dir: `${root}/${p.name}` }))))
781 return lists
782 .flat()
783 .filter(f => f.kind === 'file' && /^[0-9a-f-]{36}\.jsonl$/.test(f.name))
784 .map(f => ({ id: f.name.slice(0, 36), path: `${f.dir}/${f.name}`, mtimeMs: f.mtimeMs }))
785 .sort((a, b) => b.mtimeMs - a.mtimeMs)
786}
787
788// The last TAIL bytes of each file, '' for one it cannot read: one PowerShell per 15 files, since stdout holds
789// 4 MiB, and $.fs.read takes a whole file (at most 4 MiB) where a transcript can be far larger.
790const TAIL = 256 * 1024
791const TAIL_PS =
792 "$o=[Console]::OpenStandardOutput(); foreach($p in $env:TO_FILES -split '\\|'){ $o.WriteByte(0); try { $f=[IO.File]::Open($p,'Open','Read','ReadWrite'); try { $k=[Math]::Min([long]$env:TO_BYTES,$f.Length); [void]$f.Seek(-$k,'End'); $b=New-Object byte[] $k; $o.Write($b,0,$f.Read($b,0,$k)) } finally { $f.Close() } } catch {} }; $o.Flush()"
793// the same on macOS and Linux: tail -c per file, each preceded by a NUL
794const TAIL_SH = 'for p in "$@"; do printf "\\0"; tail -c "$TO_BYTES" "$p" 2>/dev/null; done'
795async function tails($: any, files: string[]): Promise<string[]> {
796 const out: string[] = []
797 const win = await isWindows($)
798 for (let i = 0; i < files.length; i += 15) {
799 const part = files.slice(i, i + 15)
800 const cmd = win ? ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', TAIL_PS] : ['sh', '-c', TAIL_SH, 'sh', ...part]
801 const r = await $.process
802 .run(cmd, { env: { TO_FILES: part.join('|'), TO_BYTES: String(TAIL) }, timeoutMs: 60000 })
803 .catch(() => undefined)
804 const got = r?.exitCode === 0 ? String(r.stdout).split('\0').slice(1) : []
805 out.push(...part.map((_, j) => got[j] ?? ''))
806 }
807 return out
808}
809
810const lastTitle = (text: string): string | undefined => {
811 const hit = [...text.matchAll(/"customTitle":("(?:[^"\\]|\\.)*")/g)].pop()
812 try {
813 return hit ? String(JSON.parse(hit[1] as string)) : undefined
814 } catch {
815 return undefined
816 }
817}
818
819// The model the member asked for (#63): the last requestedModel of its own loop, as typed ([1m] and a gateway's name
820// kept, /model switches followed); message.model drops [1m] and may be a gateway's own id. '' when the tail has none.
821const lastRequested = (text: string): string => {
822 const lines = text.split('\n')
823 for (let i = lines.length - 1; i >= 0; i--) {
824 const l = lines[i] as string
825 if (!l.includes('"requestedModel"')) continue
826 try {
827 const d = JSON.parse(l)
828 if (!d?.isSidechain && typeof d?.requestedModel === 'string' && d.requestedModel.trim() !== '') return d.requestedModel.trim()
829 } catch {
830 // a line cut by the tail
831 }
832 }
833 return ''
834}
835
836// The model is the one asked for (else the one that answered), kept as typed. The context percent counts against
837// windowFor's guess (200k unless [1m] or past 200k); the roster uses the member's own reported window when it has one.
838const lastStats = (text: string): Stats | undefined => {
839 const lines = text.split('\n')
840 const requested = lastRequested(text)
841 for (let i = lines.length - 1; i >= 0; i--) {
842 const l = lines[i] as string
843 if (!l.includes('"type":"assistant"')) continue
844 let d: any
845 try {
846 d = JSON.parse(l)
847 } catch {
848 continue
849 }
850 const u = d?.message?.usage
851 const model = String(d?.message?.model ?? '')
852 if (d?.type !== 'assistant' || d.isSidechain || !u || model === '' || model === '<synthetic>') continue
853 const used = Number(u.input_tokens ?? 0) + Number(u.cache_read_input_tokens ?? 0) + Number(u.cache_creation_input_tokens ?? 0)
854 return {
855 model: requested || model,
856 requested,
857 used,
858 ctx: Math.round((used * 100) / windowFor(requested || model, used)),
859 effort: typeof d.effort === 'string' ? d.effort : '',
860 cwd: typeof d.cwd === 'string' ? d.cwd : '',
861 }
862 }
863 return undefined
864}
865
866// What each transcript's tail said, kept until the file changes, so a refresh reads only the files that moved.
867const seen = new Map<string, { mtimeMs: number; title?: string; stats?: Stats }>()
868async function peek($: any, files: Transcript[]) {
869 const stale = files.filter(f => seen.get(f.path)?.mtimeMs !== f.mtimeMs)
870 const texts = await tails($, stale.map(f => f.path))
871 stale.forEach((f, i) => seen.set(f.path, { mtimeMs: f.mtimeMs, title: lastTitle(texts[i] ?? ''), stats: lastStats(texts[i] ?? '') }))
872 return files.map(f => seen.get(f.path)!)
873}
874
875// Session id per name: the newest transcript of THIS project whose last customTitle is that name. A transcript that
876// last ran outside the project folder is another team's, even when it carries the same title.
877async function sessionsNamed($: any, all: Transcript[], names: string[], root: string): Promise<Map<string, string>> {
878 const want = new Set(names)
879 const found = new Map<string, string>()
880 for (let i = 0; i < all.length && found.size < want.size; i += 15) {
881 const part = all.slice(i, i + 15)
882 ;(await peek($, part)).forEach((s, j) => {
883 const cwd = s.stats?.cwd ?? ''
884 if (cwd !== '' && !under(cwd, root)) return
885 if (s.title !== undefined && want.has(s.title) && !found.has(s.title)) found.set(s.title, (part[j] as Transcript).id)
886 })
887 }
888 return found
889}
890
891async function statsOf($: any, all: Transcript[], ids: string[]): Promise<Map<string, Stats | undefined>> {
892 const files = all.filter(t => ids.includes(t.id))
893 const got = await peek($, files)
894 return new Map(files.map((f, i) => [f.id, got[i]?.stats]))
895}
896
897// The model a member last asked for in its own transcript, as typed ([1m] kept); '' when there is none (#63).
898async function requestedModel($: any, m: Member): Promise<string> {
899 if (!m.sessionId) return ''
900 const st = (await statsOf($, await transcripts($), [m.sessionId]).catch(() => undefined))?.get(m.sessionId)
901 return st?.requested ?? ''
902}
903
904// ── Who this session is (identity.ts) ──
905// The tab, the session id and the name are matched against the roster by identify(); the answer is worked out once
906// per refresh and kept while the session id and the roster's identity fields stay the same, so a tool call does not
907// read the registry or a transcript.
908type Reg = { sessionId: string; cwd: string; name: string; pid: number }
909// The machine's session registry: <config dir>/sessions/<pid>.json, one per live local session. Only the id, the
910// folder, the name and the process id are taken; the .key files beside them are never read. ok: some registry folder
911// could be listed, so an empty answer means "no session", not "unreadable" (the crash check of 0.5.16 needs the difference).
912async function readRegistry($: any): Promise<{ ok: boolean; list: Reg[] }> {
913 const out: Reg[] = []
914 let ok = false
915 for (const dir of [...new Set(await claudeDirs($))]) {
916 const at = `${dir}/sessions`
917 let listed: any[]
918 try {
919 listed = (await $.fs.list(at)) as any[]
920 ok = true
921 } catch {
922 continue
923 }
924 const files = listed.filter(f => f.kind === 'file' && /^\d+\.json$/.test(String(f.name)))
925 for (const f of files) {
926 try {
927 const o = JSON.parse(String(await $.fs.read(`${at}/${f.name}`)))
928 const pid = Number.isInteger(o?.pid) && o.pid > 0 ? (o.pid as number) : Number(String(f.name).replace(/\.json$/, ''))
929 if (typeof o?.sessionId === 'string') out.push({ sessionId: o.sessionId, cwd: String(o.cwd ?? ''), name: typeof o.name === 'string' ? o.name : '', pid })
930 } catch {
931 // a record being rewritten: skip it this time
932 }
933 }
934 }
935 return { ok, list: out }
936}
937const registry = async ($: any): Promise<Reg[]> => (await readRegistry($)).list
938
939type Who = { me?: Member; level: Level; why: string }
940const NOBODY: Who = { level: 'none', why: '' }
941const sigOf = (list: Member[]) => list.map(m => [m.team, m.name, m.address ?? '', m.handle, m.sessionId, m.boss, m.machine ?? ''].join('|')).join('\n')
942const self = {
943 cur: undefined as undefined | { id: string; sig: string; key: string; level: Level; why: string },
944 busy: undefined as undefined | Promise<Who>,
945}
946// the one-time note this session's next prompt carries: the role-file pointer after a re-attach, or the hold notice
947// (or the question about taking over the team top); several notes for one session ride together
948let pendingNote: { id: string; text: string } | undefined
949const addNote = (id: string, text: string) => {
950 pendingNote = pendingNote && pendingNote.id === id ? (pendingNote.text.includes(text) ? pendingNote : { id, text: `${pendingNote.text}\n\n${text}` }) : { id, text }
951}
952
953async function identity($: any, list0?: Member[]): Promise<Who> {
954 const list = list0 ?? (await readMembers($))
955 if (list.length === 0) return NOBODY
956 const id = String(await $.session.id().catch(() => ''))
957 const c = self.cur
958 if (c && c.id === id && c.sig === sigOf(list)) {
959 if (c.level === 'none') return NOBODY
960 const me = list.find(m => keyOf(m) === c.key)
961 if (me) return { me, level: c.level, why: c.why }
962 }
963 if (!self.busy) self.busy = identifyNow($, list, id).finally(() => void (self.busy = undefined))
964 return self.busy
965}
966
967async function identifyNow($: any, list: Member[], id: string): Promise<Who> {
968 const none = () => undefined
969 const tab = String((await $.env.get('ORCA_TERMINAL_HANDLE').catch(none)) ?? '').trim()
970 const here = await machineOf($)
971 const root = await rootOf($)
972 const reg = id ? await registry($) : []
973 const mine = reg.filter(r => r.sessionId === id)
974 const local = mine.some(r => under(r.cwd, root))
975 let name = mine.find(r => r.name !== '')?.name ?? ''
976 if (name === '' && id !== '') {
977 const own = (await transcripts($)).filter(t => t.id === id)
978 if (own.length > 0) name = (await peek($, own))[0]?.title ?? ''
979 }
980 // of the members this session might be, the ones running elsewhere: another live session holds their id, or their
981 // own tab is open (Orca is asked only about those members, and only when this tab does not settle it)
982 const others = new Set(reg.filter(r => r.sessionId !== id).map(r => r.sessionId))
983 const live = new Set<string>()
984 for (const m of list) {
985 // a member from another PC: its tab and its registry are that PC's, never asked about here
986 if (isAway(m, here)) continue
987 if (!((id !== '' && m.sessionId === id) || (name !== '' && namesOf(m).includes(name)))) continue
988 if (m.sessionId && m.sessionId !== id && others.has(m.sessionId)) live.add(keyOf(m))
989 else if (m.handle && m.handle !== tab && (await showTab($, m.handle))) live.add(keyOf(m))
990 }
991 const r = identify({ list, facts: { tab, sessionId: id, name }, local, live, here })
992 const me = r.member
993 if (r.changed && me) {
994 if (r.renamedFrom) await moveFiles($, r.renamedFrom, me.name)
995 await update($, members, () => r.list)
996 await share($, { me, level: r.level, why: r.why })
997 if (r.renamedFrom) await $.ui.toast(`Team Orchestrator: ${r.renamedFrom} is now ${me.name} (renamed).`)
998 }
999 if (r.reattached && me) addNote(id, roleNote(me))
1000 if (r.level === 'restricted' && me) await holdOnce($, me, r.why, { sessionId: id, tab, name, machine: here })
1001 self.cur = { id, sig: sigOf(r.list), key: me ? keyOf(me) : '', level: r.level, why: r.why }
1002 return { me, level: r.level, why: r.why }
1003}
1004
1005// A relabelled member's files follow its new name: the role file (the person's notes in it kept) and the status file.
1006// The old files are left as pointers to the new ones.
1007async function moveFiles($: any, from: string, to: string) {
1008 const dir = await teamDir($)
1009 const [ro, rn] = [`${dir}/${roleFile(from)}`, `${dir}/${roleFile(to)}`]
1010 if (ro !== rn && (await $.fs.exists(ro))) {
1011 if (!(await $.fs.exists(rn))) await $.fs.write(rn, String(await $.fs.read(ro)))
1012 await $.fs.write(ro, `# ${from} was renamed\n\n${from} is now ${to}. Its role file is .claude/team-orchestrator/${roleFile(to)}.\n`)
1013 roleSeen.delete(ro)
1014 }
1015 const s = await readStatus($, from)
1016 if (s && statusFile(from) !== statusFile(to)) {
1017 await $.fs.write(await statusPath($, to), JSON.stringify({ ...s, name: to }, null, 1))
1018 await $.fs.write(await statusPath($, from), JSON.stringify({ name: from, movedTo: statusFile(to) }, null, 1))
1019 }
1020}
1021
1022// Held sessions waiting for the user's decision, one per session id: <team folder>/claims.json.
1023const claimsFile = async ($: any) => `${await teamDir($)}/claims.json`
1024async function readClaims($: any): Promise<Claim[]> {
1025 const p = await claimsFile($)
1026 if (!(await $.fs.exists(p))) return []
1027 try {
1028 const c = JSON.parse(String(await $.fs.read(p)))
1029 return Array.isArray(c) ? c : []
1030 } catch {
1031 return []
1032 }
1033}
1034const writeClaims = async ($: any, c: Claim[]) => $.fs.write(await claimsFile($), JSON.stringify(c, null, 1))
1035
1036// Once per session id: record the claim, warn the member's head, and tell the team top to ask the user at once.
1037async function holdOnce($: any, x: Member, why: string, f: { sessionId: string; tab: string; name: string; machine: string }) {
1038 const claims = await readClaims($)
1039 if (claims.some(c => c.sessionId === f.sessionId)) return
1040 const c: Claim = { sessionId: f.sessionId, member: x.name, team: x.team, tab: f.tab, name: f.name, why, at: Date.now(), ...(f.machine ? { machine: f.machine } : {}) }
1041 await writeClaims($, [...claims, c])
1042 addNote(f.sessionId, holdNote(x, why))
1043 const list = await readMembers($)
1044 const head = x.boss === 'user' ? undefined : (list.find(m => m.team === x.team && m.name === x.boss) ?? list.find(m => m.name === x.boss))
1045 const top = topOf(list, x)
1046 const say = async (to: Member | undefined, text: string) => {
1047 if (!to?.sessionId) return false
1048 const r: any = await $.session.send({ to: { sessionId: to.sessionId }, text }).catch(() => undefined)
1049 return !!r?.isDelivered
1050 }
1051 const told: string[] = []
1052 const ask = topAsk(c, x.boss === 'user' ? x.name : x.boss)
1053 if (head && top && keyOf(head) === keyOf(top)) {
1054 if (await say(top, `${headWarning(c)}\n\n${ask}`)) told.push(top.name)
1055 } else {
1056 if (head && (await say(head, headWarning(c)))) told.push(head.name)
1057 if (top && (await say(top, ask))) told.push(top.name)
1058 }
1059 await $.ui.toast(
1060 `Team Orchestrator: this session is on hold; it looks like ${x.name} but is not confirmed. ` +
1061 (told.length > 0 ? `Told ${told.join(' and ')}.` : 'Nobody on the team could be told: tell the team top yourself.'),
1062 )
1063}
1064
1065// The team top applies the user's answer to a held session (member_claim).
1066async function settleClaim($: any, input: { sessionId?: string; decision?: string; member?: string }): Promise<string> {
1067 const claims = await readClaims($)
1068 const c = claims.find(x => x.sessionId === String(input.sessionId ?? '').trim())
1069 if (!c) return `No session ${String(input.sessionId ?? '')} is waiting for an identity decision.`
1070 const decision = String(input.decision ?? '')
1071 if (decision !== 'is' && decision !== 'new' && decision !== 'reject') return 'decision must be "is", "new" or "reject".'
1072 const list = await readMembers($)
1073 const wanted = String(input.member ?? '').trim() || c.member
1074 const x = list.find(m => m.team === c.team && (m.name === wanted || m.address === wanted)) ?? list.find(m => m.name === wanted || m.address === wanted)
1075 if (!x && decision !== 'reject') return `No member "${wanted}" on the roster.`
1076 const tell = (text: string) => $.session.send({ to: { sessionId: c.sessionId }, text }).catch(() => undefined)
1077 // whoever held the session's id or tab lets go of it
1078 const letGo = (m: Member) => ({ ...m, ...(m.sessionId === c.sessionId ? { sessionId: '' } : {}), ...(c.tab && m.handle === c.tab ? { handle: '' } : {}) })
1079 let out = `Rejected: session ${c.sessionId} stays on hold and off the team.`
1080 if (decision === 'reject' || !x) {
1081 await tell('TEAM ORCHESTRATOR: the user did not take this session onto the team. It stays on hold: no writes, no subagents, no team tools.')
1082 } else if (decision === 'is') {
1083 let next = list.map(m => (m === x ? { ...m, sessionId: c.sessionId, ...(c.tab ? { handle: c.tab } : {}), ...(c.machine ? { machine: c.machine } : {}) } : letGo(m)))
1084 let name = x.name
1085 if (c.name && !namesOf(x).includes(c.name) && !nameTaken(list, c.name, x)) {
1086 next = relabel(next, keyOf(x), c.name)
1087 await moveFiles($, x.name, c.name)
1088 name = c.name
1089 }
1090 await update($, members, () => next)
1091 out = `Session ${c.sessionId} is ${name} of team ${x.team}: the roster took its id${c.tab ? ', tab' : ''} and name.`
1092 await tell(`TEAM ORCHESTRATOR: the user confirmed it. ${roleNote({ ...x, name })}`)
1093 } else {
1094 const boss = x.boss === 'user' ? x : (list.find(m => m.team === x.team && m.name === x.boss) ?? x)
1095 const name = freshName(list, c.name, x.name)
1096 const added: Member = {
1097 team: x.team, name, address: name, role: 'worker', level: boss.level + 1, boss: boss.name, handle: c.tab, sessionId: c.sessionId,
1098 state: 'idle', ctx: -1, model: '', effort: '', sel: false, note: '', briefed: false, noted: false, statusFile: statusFile(name), location: 'local',
1099 ...(c.machine ? { machine: c.machine } : {}),
1100 }
1101 await update($, members, () => [...list.map(letGo), added])
1102 out = `Added ${name} to team ${x.team} as a worker under ${boss.name}, with its own role file.`
1103 await tell(`TEAM ORCHESTRATOR: the user added this session to the team as a new member. ${roleNote(added)}${name !== c.name ? ` Run /rename ${name} so teammates reach you by that name.` : ''}`)
1104 }
1105 await writeClaims($, claims.map(x => (x.sessionId === c.sessionId ? { ...x, decision: decision as Claim['decision'] } : x)))
1106 await share($)
1107 self.cur = undefined
1108 return out
1109}
1110
1111// A team tool asked by a held session: refused with a sentence that says why.
1112async function onHold($: any, tool: string): Promise<any> {
1113 await pull($)
1114 const w = await identity($)
1115 return w.level === 'restricted' && w.me ? { deny: holdTool(tool, w.me, w.why) } : undefined
1116}
1117
1118// The person's own Claude config folders: where a memory folder lives (<dir>/projects/<project>/memory/).
1119async function claudeDirs($: any): Promise<string[]> {
1120 const none = () => undefined
1121 const home = (await $.env.get('USERPROFILE').catch(none)) || (await $.env.get('HOME').catch(none))
1122 const custom = await $.env.get('CLAUDE_CONFIG_DIR').catch(none)
1123 return [custom, home ? `${home}/.claude` : ''].filter((x): x is string => !!x)
1124}
1125
1126// Whether this session is the one that polls Orca for the roster (see refresh).
1127// A held session never polls: it does no roster work until the user decides.
1128async function pollsOrca($: any, list: Member[]): Promise<boolean> {
1129 const w = await identity($, list)
1130 return w.level !== 'restricted' && shouldPoll(w.level === 'full' ? w.me : undefined)
1131}
1132
1133// This session's roster entry and the roster, or undefined when the session is not a member.
1134async function rosterSelf($: any): Promise<{ me: Member; list: Member[] } | undefined> {
1135 await pull($)
1136 const list = await readMembers($)
1137 if (list.length === 0) return undefined
1138 // only a session confirmed as the member: a held one writes no status and gets no notes under its name
1139 const w = await identity($, list)
1140 return w.level === 'full' && w.me ? { me: w.me, list } : undefined
1141}
1142
1143// ── Member status files (see status.ts) ────────────────────────────────────────────────────────────────────
1144// From 0.5.12 (#64) status files live on the machine, never in the project folder (which may be synced between PCs):
1145// <claude config dir>/team-orchestrator/<project key>/status/<name>.json. That removes most of the team's file traffic
1146// from a synced folder, and every sync-lag and clock-skew error with it.
1147async function configDir($: any): Promise<string> {
1148 const none = () => undefined
1149 const home = (await $.env.get('USERPROFILE').catch(none)) || (await $.env.get('HOME').catch(none))
1150 return String((await $.env.get('CLAUDE_CONFIG_DIR').catch(none)) || (home ? `${home}/.claude` : '')).replace(/[\\/]+$/, '')
1151}
1152async function machineDir($: any): Promise<string> {
1153 const dir = await configDir($)
1154 return dir ? `${dir}/team-orchestrator/${projectKey(await rootOf($))}` : await teamDir($)
1155}
1156const statusPath = async ($: any, name: string) => `${await machineDir($)}/${statusFile(name)}`
1157
1158// A status file of an older version, in the project folder, is read once per member and session and copied here.
1159const statusMigrated = new Set<string>()
1160async function readStatus($: any, name: string): Promise<Status | undefined> {
1161 const p = await statusPath($, name)
1162 if (await $.fs.exists(p)) {
1163 try {
1164 return JSON.parse(String(await $.fs.read(p))) as Status
1165 } catch {
1166 return undefined
1167 }
1168 }
1169 if (statusMigrated.has(p)) return undefined
1170 statusMigrated.add(p)
1171 const old = `${await teamDir($)}/${statusFile(name)}`
1172 if (old === p || !(await $.fs.exists(old))) return undefined
1173 try {
1174 const s = JSON.parse(String(await $.fs.read(old)))
1175 if (!s || typeof s !== 'object' || typeof s.heartbeat !== 'number') return undefined
1176 await $.fs.write(p, JSON.stringify(s, null, 1))
1177 return s as Status
1178 } catch {
1179 return undefined
1180 }
1181}
1182
1183// Members of this machine only: a member from another PC writes its status there.
1184async function readStatuses($: any, list0: Member[]): Promise<Map<string, Status>> {
1185 const here = await machineOf($)
1186 const list = list0.filter(m => !isAway(m, here))
1187 const got = await Promise.all(list.map(async m => [m.name, await readStatus($, m.name)] as const))
1188 return new Map(got.filter((x): x is readonly [string, Status] => !!x[1]))
1189}
1190
1191// A member writes only its own file. The one exception: the team top marks a worker it closed as "closed".
1192async function writeStatusOf($: any, m: Member, patch: Partial<Status>) {
1193 const now = Date.now()
1194 const old = (await readStatus($, m.name)) ?? { name: m.name, sessionId: m.sessionId, state: 'idle', heartbeat: now }
1195 await $.fs.write(await statusPath($, m.name), JSON.stringify({ ...old, ...patch, name: m.name }, null, 1))
1196}
1197
1198// Every caller fires it without waiting, so it never throws: a missed heartbeat is written by the next one.
1199async function writeMine($: any, patch: Partial<Status>) {
1200 try {hooks/adding.ts 131 lines1// New members on a running team (0.5.17, #77). Pure rules from the roster alone (no $), so a test can call them.
2//
3// - New member: a member saved "not yet" (pending), with its role file, that starts fresh and briefed on its boss's
4// first team_message, exactly like a member Create leaves for later.
5// - Who may add: you always may (the panel, or your own session that is not on the roster). A head or a lead may only
6// REQUEST members, and only within its purview: its own team and every team whose chain of bosses leads up to it.
7// A request takes effect once you approve it, asked by the team top with AskUserQuestion.
8// - A launch under a team name already on the roster never replaces that team: it is refused, and offers "add" (every
9// launched member joins as a new member) or "merge" (the launched team merges into the one there, by name).
10
11import type { Member } from '../types'
12
13export const EFFORTS = ['default', 'low', 'medium', 'high', 'xhigh', 'max']
14
15const clean = (s: string) => s.replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 40)
16const keyOf = (m: Member) => `${m.team}|${m.name}`
17
18/**
19 * What a New member is made from. boss '' is the team's head; model and effort 'default' or '' leave it to claude.
20 * by: the head or lead that asked for it (member_add); it may be the boss from the team above.
21 */
22export type NewSpec = { team: string; name: string; role?: string; boss?: string; model?: string; effort?: string; by?: string }
23
24/** A member's boss as the roster names it: the one in its own team first, else the one of that name in any team. */
25export const bossOf = (list: Member[], m: Member): Member | undefined =>
26 m.boss === 'user' ? undefined : (list.find(x => x.team === m.team && x.name === m.boss) ?? list.find(x => x.name === m.boss))
27
28/** The team's head: its first member whose boss is not in the team. */
29export const headOf = (list: Member[], team: string): Member | undefined => {
30 const mine = list.filter(m => m.team === team)
31 return mine.find(m => !mine.some(x => x.name === m.boss))
32}
33
34/** Does `who` sit on m's chain of bosses (m itself not counted)? */
35function under(list: Member[], m: Member, who: Member): boolean {
36 const seen = new Set<string>()
37 for (let b = bossOf(list, m); b && !seen.has(keyOf(b)); b = bossOf(list, b)) {
38 if (keyOf(b) === keyOf(who)) return true
39 seen.add(keyOf(b))
40 }
41 return false
42}
43
44/** A head (reports to the user, or level 1) or a lead (has reports): the members who may ask for new members. */
45export const manages = (list: Member[], me: Member) => me.boss === 'user' || me.level === 1 || list.some(k => keyOf(k) !== keyOf(me) && bossOf(list, k) && keyOf(bossOf(list, k)!) === keyOf(me))
46
47/** The teams in a head's or lead's purview: its own, then every team whose head's chain of bosses leads up to it. */
48export function purview(list: Member[], me: Member): string[] {
49 const teams = [...new Set(list.map(m => m.team))]
50 return [me.team, ...teams.filter(t => t !== me.team && list.some(m => m.team === t && headOf(list, t) === m && under(list, m, me)))]
51}
52
53/** Why a head or lead may not ask for a member of `team` under `boss`: '' when it may. */
54export function requestProblem(list: Member[], me: Member, team: string, boss = ''): string {
55 if (!manages(list, me)) return `${me.name} is a worker: only a head or a lead may ask for new members. Ask your boss.`
56 const p = purview(list, me)
57 if (!p.includes(team)) return `Refused: team "${team}" is outside ${me.name}'s purview (${p.join(', ')}). A head or lead may ask for members only in its own team and the teams below it.`
58 const b = boss && boss !== me.name ? (list.find(m => m.team === team && m.name === boss) ?? list.find(m => m.name === boss)) : undefined
59 if (b && !p.includes(b.team)) return `Refused: the boss "${boss}" (team ${b.team}) is outside ${me.name}'s purview (${p.join(', ')}).`
60 return ''
61}
62
63/** Why a New member cannot be added as given: '' when it can. Names are unique across the project (SendMessage finds a session by name). */
64export function newProblem(list: Member[], s: NewSpec): string {
65 const team = String(s.team ?? '').trim()
66 if (!list.some(m => m.team === team)) return `No team "${team}" on the roster. New members join a team that is running; start a new team from New team (or team_launch).`
67 const name = clean(String(s.name ?? '').trim())
68 if (name === '') return 'Give the new member a name.'
69 const taken = list.find(m => m.name === name || m.address === name)
70 if (taken) return `The name "${name}" is taken (team ${taken.team}). Pick another: a name is how teammates reach a session.`
71 const boss = String(s.boss ?? '').trim()
72 const asker = boss !== '' && boss === s.by && list.some(m => m.name === boss)
73 if (boss !== '' && !asker && !list.some(m => m.team === team && m.name === boss)) return `No member "${boss}" in team "${team}" to be the boss.`
74 const effort = String(s.effort ?? '').trim()
75 if (effort !== '' && !EFFORTS.includes(effort)) return `Effort "${effort}" is not one of ${EFFORTS.join(', ')}.`
76 return ''
77}
78
79/** The New member as the roster keeps it (no worktree or machine yet: the caller adds those). Check newProblem first. */
80export function newMember(list: Member[], s: NewSpec): Member {
81 const name = clean(String(s.name).trim())
82 const boss = String(s.boss ?? '').trim()
83 // the boss in the team, else the head or lead that asked (it may sit in the team above), else the team's head
84 const b = boss ? (list.find(m => m.team === s.team && m.name === boss) ?? (boss === s.by ? list.find(m => m.name === boss) : undefined)) : headOf(list, s.team)
85 const model = String(s.model ?? '').trim()
86 const effort = String(s.effort ?? '').trim()
87 return {
88 team: s.team, name, address: name, role: String(s.role ?? '').trim() || 'worker', level: b ? b.level + 1 : 1, boss: b ? b.name : 'user',
89 handle: '', sessionId: '', state: 'unstarted', ctx: -1, model: model === 'default' ? '' : model, effort: effort === 'default' ? '' : effort,
90 sel: false, note: '', briefed: false, noted: false, pending: true, location: 'local',
91 }
92}
93
94/** A launched member, as team_launch and the form give it (team cleaned). */
95export type LaunchSpec = { name: string; role: string; level: number; boss: string; team: string; model?: string; effort?: string; short?: string }
96
97/**
98 * A launch into teams already on the roster (mode add or merge). Nobody on the roster is changed or removed.
99 * - add: every launched member is new; a name taken anywhere gets a number (Worker-1 -> Worker-1-2).
100 * - merge: a launched member named like a member of that team is that member, kept as it is; the rest join.
101 * Either way a launched member that would report to the user, in a team already there, reports to that team's head
102 * instead (a team has one top). Returns the members to add (boss-first) and the names merged into members there.
103 */
104export function joinLaunch(list: Member[], specs: LaunchSpec[], mode: 'add' | 'merge'): { add: LaunchSpec[]; merged: string[] } {
105 const there = new Set(list.map(m => m.team))
106 const taken = new Set(list.flatMap(m => [m.name, m.address ?? m.name]))
107 const renamed = new Map<string, string>()
108 const levels = new Map<string, number>(list.map(m => [`${m.team}|${m.name}`, m.level]))
109 const add: LaunchSpec[] = []
110 const merged: string[] = []
111 for (const s of specs) {
112 const base = clean(s.name)
113 if (mode === 'merge' && there.has(s.team) && list.some(m => m.team === s.team && m.name === base)) {
114 renamed.set(s.name, base)
115 merged.push(base)
116 continue
117 }
118 let name = base
119 if (taken.has(name) && !there.has(s.team)) name = clean(`${s.team}-${base}`)
120 for (let i = 2; taken.has(name); i++) name = clean(`${base}-${i}`)
121 taken.add(name)
122 renamed.set(s.name, name)
123 const head = headOf(list, s.team)
124 const boss = s.boss === 'user' ? (there.has(s.team) && head ? head.name : 'user') : (renamed.get(s.boss) ?? clean(s.boss))
125 const level = boss === 'user' ? 1 : (levels.get(`${s.team}|${boss}`) ?? [...levels].find(([k]) => k.endsWith(`|${boss}`))?.[1] ?? s.level - 1) + 1
126 levels.set(`${s.team}|${name}`, level)
127 add.push({ ...s, name, boss, level })
128 }
129 return { add, merged }
130}
131hooks/guard.ts 227 lines1// What a roster member may do with the Agent tool (subagents) and with Write, Edit and NotebookEdit, from the roster
2// and the grants alone: no $, no state, so a test can call it.
3//
4// - Every roster member is refused the Agent tool, unless the person allowed it (see below).
5// - A member that is somebody's boss (a head, a CEO) is refused Write, Edit and NotebookEdit, except in its own
6// memory folder, unless the person allowed it.
7// - A session that is not on the roster is never judged.
8//
9// The person allows in two ways: a standing switch per member (Settings: "Allow subagents", "Allow writes", kept in
10// the roster file), or one turn at a time by typing #allow-subagent or #allow-write in the prompt. Only a prompt the
11// person typed at the terminal counts (origin kind "composer"); a message from another session, a tool result, a
12// pasted text or a plugin's own prompt never allows anything.
13
14import type { Member } from '../types'
15
16export const AGENT_TOOL = 'Agent'
17export const WRITE_TOOLS = ['Write', 'Edit', 'NotebookEdit'] as const
18export const KEYWORDS = { agent: '#allow-subagent', write: '#allow-write' } as const
19
20export type Grants = { agent: boolean; write: boolean }
21export const NO_GRANTS: Grants = { agent: false, write: false }
22
23const has = (text: string, word: string) => new RegExp(`(^|\\s)${word.replace(/[-]/g, '\\-')}(?=$|[\\s.,;:!?)])`, 'i').test(text)
24
25/**
26 * The grants one prompt carries, or undefined when the prompt is not the person's own typing (it then changes
27 * nothing). A later prompt of the person's replaces the grants, so a prompt without the keyword takes them back.
28 */
29export function grantsFrom(origin: { kind?: string } | undefined, text: string): Grants | undefined {
30 if (origin?.kind !== 'composer') return undefined
31 return { agent: has(text, KEYWORDS.agent), write: has(text, KEYWORDS.write) }
32}
33
34/** Somebody reports to this member. */
35export const isBoss = (m: Member, list: Member[]) => list.some(x => x !== m && x.boss === m.name)
36
37/**
38 * The path is inside <dir>/projects/<project>/memory/ for one of the Claude config folders given (the person's own
39 * memory folders). A path with ".." in it is never taken as inside.
40 */
41export function isMemoryPath(path: string, claudeDirs: string[]): boolean {
42 const p = path.replace(/\\/g, '/').toLowerCase()
43 if (p.split('/').includes('..')) return false
44 return claudeDirs.some(d => {
45 const root = `${d.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()}/projects/`
46 return d !== '' && p.startsWith(root) && /^[^/]+\/memory\/./.test(p.slice(root.length))
47 })
48}
49
50const SEND = 'Hand the work to your worker with SendMessage, or allow it yourself'
51const SEND_ZH = '把工作用 SendMessage 交給你的 worker,或自行授權'
52
53/**
54 * Claude Code's own helper agents that a slash command starts (/statusline, the Claude Code guide): they only change
55 * the person's settings or answer questions about Claude Code, so they pass the Agent guard (0.5.13, #76).
56 */
57export const HELPER_AGENTS = ['statusline-setup', 'claude-code-guide'] as const
58
59export type Verdict =
60 | { kind: 'deny'; reason: string; line: string }
61 | { kind: 'allow'; line: string }
62 | undefined
63
64/** The pathOf of a call: where a Write, Edit or NotebookEdit goes. */
65export const pathOf = (e: Record<string, unknown>): string => String(e.file_path ?? e.notebook_path ?? '')
66
67/**
68 * Judge one tool call of the session `me` (a roster member). undefined: nothing to say, let it through. subagentType is
69 * the Agent call's subagent_type.
70 */
71export function judge(args: { me: Member; list: Member[]; tool: string; path: string; grants: Grants; claudeDirs: string[]; subagentType?: string }): Verdict {
72 const { me, list, tool, path, grants, claudeDirs } = args
73 if (tool === AGENT_TOOL) {
74 const kind = String(args.subagentType ?? '').trim()
75 if ((HELPER_AGENTS as readonly string[]).includes(kind)) return { kind: 'allow', line: `allowed Agent: ${me.name} (${kind}, a built-in Claude Code helper)` }
76 if (me.allowAgent) return { kind: 'allow', line: `allowed Agent: ${me.name} (Allow subagents is on)` }
77 if (grants.agent) return { kind: 'allow', line: `allowed Agent: ${me.name} (${KEYWORDS.agent}, this turn only)` }
78 return {
79 kind: 'deny',
80 reason: `Blocked Agent: ${me.name} has no subagent permission. ${SEND} (Settings → Allow subagents, or type ${KEYWORDS.agent} in your next message). ${SEND_ZH}(設定 → Allow subagents,或在下一則訊息輸入 ${KEYWORDS.agent})。`,
81 line: `blocked Agent: ${me.name} has no subagent permission`,
82 }
83 }
84 if (!(WRITE_TOOLS as readonly string[]).includes(tool) || !isBoss(me, list)) return undefined
85 if (isMemoryPath(path, claudeDirs)) return undefined
86 if (me.allowWrite) return { kind: 'allow', line: `allowed ${tool}: ${me.name} (Allow writes is on)` }
87 if (grants.write) return { kind: 'allow', line: `allowed ${tool}: ${me.name} (${KEYWORDS.write}, this turn only)` }
88 return {
89 kind: 'deny',
90 reason: `Blocked ${tool}: ${me.name} has reports, so it does not write files itself (its own memory folder is open). ${SEND} (Settings → Allow writes, or type ${KEYWORDS.write} in your next message). ${SEND_ZH}(設定 → Allow writes,或在下一則訊息輸入 ${KEYWORDS.write})。`,
91 line: `blocked ${tool}: ${me.name} has reports and no write permission`,
92 }
93}
94
95// ── The team files (0.5.11, #58) ──────────────────────────────────────────────────────────────────────────────
96// roster.json and settings.json in .claude/team-orchestrator/ carry every member's rights (Allow writes, Allow
97// subagents) and the team's settings (auto-approve, the session cap). Only the mod's own code (it writes through
98// $.fs, not a tool), sessions that are not on the roster (the person's own) and the team top (boss "user") may change
99// them. Every other member is refused Write, Edit and NotebookEdit on them, and any Bash or PowerShell command that
100// names them and is not plainly read-only. From 0.5.12 (#59) the same holds for meta.json (the schema stamp) and the
101// change files in changes/, which the top folds into the roster: a forged change file would be a forged roster.
102// From 0.5.17 (#77) also the member requests in requests/, which the top puts to the user: a forged one would ask
103// under a false name. Only member_add writes them. roles/ and queue/ stay writable; status files live on each machine.
104
105export const SHELL_TOOLS = ['Bash', 'PowerShell'] as const
106
107// one path segment as Windows reads it: no alternate stream (":$DATA"), no trailing dots or spaces
108const plain = (s: string) => s.replace(/:.*$/, '').replace(/[. ]+$/, '')
109const TEAM_DIR = /^(team-orchestrator|team-o~\d+)$/
110const TEAM_FILE = /^(roster\.json|settings\.json|meta\.json|roster~\d+\.jso|settin~\d+\.jso|meta~\d+\.jso)$/
111const CHANGES_DIR = /^(changes|change~\d+|requests)$/
112
113/**
114 * The path is .claude/team-orchestrator/roster.json, settings.json or meta.json, or a file in its changes/ or requests/ folder (any
115 * root; "." and ".." folded; 8.3 names too).
116 */
117export function isTeamFilePath(path: string): boolean {
118 const segs: string[] = []
119 for (const s of path.replace(/\\/g, '/').toLowerCase().split('/')) {
120 if (s === '' || s === '.') continue
121 if (s === '..') segs.pop()
122 else segs.push(s)
123 }
124 const n = segs.length
125 if (n >= 3 && CHANGES_DIR.test(plain(segs[n - 2] as string)) && TEAM_DIR.test(plain(segs[n - 3] as string))) return true
126 return n >= 2 && TEAM_FILE.test(plain(segs[n - 1] as string)) && TEAM_DIR.test(plain(segs[n - 2] as string))
127}
128
129/**
130 * The command names a team file: roster.json anywhere; settings.json beside the team folder's name or bare (the shell
131 * may stand in the team folder); or the team folder with a wildcard, a variable or a substitution (the target is
132 * then unknowable). Best effort: a name built at run time is not seen.
133 */
134export function namesTeamFile(command: string): boolean {
135 const c = command.replace(/\\/g, '/').toLowerCase()
136 const dir = /team-orchestrator|team-o~\d/.test(c)
137 if (/roster(\.json|~\d)/.test(c)) return true
138 if (/(team-orchestrator|team-o~\d)\/+(changes|change~\d|requests)\b/.test(c)) return true
139 if (dir && /meta(\.json|~\d)/.test(c)) return true
140 if (/settin(gs\.json|~\d)/.test(c) && (dir || /(^|[\s'"=(,;|&<>])settings\.json/.test(c))) return true
141 return dir && /[*?[\]{}$`]/.test(c)
142}
143
144const READERS = /^(cat|type|get-content|gc|ls|dir|get-childitem|gci|grep|select-string|sls|jq)$/
145// a redirect that only drops output or merges stderr writes nothing
146const HARMLESS = /\d?>>?\s*(&\d|\/dev\/null|\$null|nul)(?=$|[\s;|&)])/gi
147
148/** Why the command is not plainly read-only, or '' when it is: only cat, type, Get-Content, ls, dir, grep, Select-String or jq (without -i). */
149export function notReadOnly(command: string): string {
150 if (/`|\$\(|\$\{|<\(|>\(/.test(command)) return 'it runs a substitution, so what it does is unclear'
151 // quoted text is an argument (a jq filter, a grep pattern), never a redirect or a second command
152 const c = command.replace(/'[^']*'|"[^"]*"/g, ' Q ').replace(HARMLESS, ' ')
153 if (/['"]/.test(c)) return 'its quoting is unbalanced, so what it does is unclear'
154 if (/>/.test(c)) return 'it redirects output into a file'
155 const parts = c.replace(/<\s*\S+/g, ' ').split(/&&|\|\||[;|&\n\r]/).map(p => p.trim()).filter(p => p !== '')
156 if (parts.length === 0) return 'it is empty'
157 for (const p of parts) {
158 const w = p.split(/\s+/)
159 const first = (w[0] ?? '').toLowerCase()
160 if (!READERS.test(first)) return `it runs "${first}", which is not a plain read (cat, type, Get-Content, ls, dir, grep, Select-String, jq)`
161 if (first === 'jq' && w.some(x => x === '--in-place' || /^-[a-z]*i[a-z]*$/i.test(x))) return 'it runs jq -i, which writes in place'
162 }
163 return ''
164}
165
166const LOCKED = '.claude/team-orchestrator/roster.json, settings.json, meta.json, changes/ and requests/'
167
168/**
169 * Judge one call against the team files. `confirmed` is false for a held session, which is locked whatever its boss.
170 * undefined: the call does not touch them, or this member may (the team top).
171 */
172export function judgeTeamFiles(args: { me: Member; confirmed: boolean; tool: string; path: string; command: string }): Verdict {
173 const { me, confirmed, tool, path, command } = args
174 if (confirmed && me.boss === 'user') return undefined
175 const who = `${me.name}${confirmed ? '' : ' (on hold)'}`
176 const tail = `Only the team top and the Team Orchestrator itself change ${LOCKED}; ask the team top, or the user (Settings in the Team Orchestrator panel).`
177 if ((WRITE_TOOLS as readonly string[]).includes(tool)) {
178 if (!isTeamFilePath(path)) return undefined
179 return { kind: 'deny', reason: `Blocked ${tool}: ${who} may not change the team file ${path}. ${tail}`, line: `blocked ${tool}: ${me.name} on a team file` }
180 }
181 if (!(SHELL_TOOLS as readonly string[]).includes(tool) || !namesTeamFile(command)) return undefined
182 const why = notReadOnly(command)
183 if (why === '') return undefined
184 return {
185 kind: 'deny',
186 reason: `Blocked ${tool}: this command names a team file (${LOCKED}) and is not plainly read-only: ${why}. ${who} may only read them (cat, type, Get-Content, ls, dir, grep, Select-String, jq without -i), one plain command at a time. ${tail}`,
187 line: `blocked ${tool}: ${me.name} on a team file`,
188 }
189}
190
191// ── Grants changed outside the mod (0.5.11, #58) ──
192// What the mod last wrote for each member's Allow writes and Allow subagents is recorded beside the roster; the team
193// top's refresh compares the file with it and reports a difference. Nothing is reverted.
194
195export type GrantMap = Record<string, { agent: boolean; write: boolean }>
196
197/** team|name → the two standing switches, from roster rows. */
198export const grantMap = (rows: Partial<Member>[]): GrantMap =>
199 Object.fromEntries(rows.filter(r => typeof r?.name === 'string').map(r => [`${r.team ?? ''}|${r.name}`, { agent: r.allowAgent === true, write: r.allowWrite === true }]))
200
201const same = (a?: { agent: boolean; write: boolean }, b?: { agent: boolean; write: boolean }) => !!a && !!b && a.agent === b.agent && a.write === b.write
202
203/**
204 * The record after the mod writes the roster. A member whose switches this write changed (or that is new) is recorded
205 * as written; one the write left as the file had it keeps its earlier record, so a change somebody else made to the
206 * file, pulled in and written back unchanged, is not taken as the mod's.
207 */
208export function recordAfterWrite(prev: GrantMap | undefined, before: GrantMap, written: GrantMap): GrantMap {
209 const out: GrantMap = {}
210 for (const [k, v] of Object.entries(written)) out[k] = same(before[k], v) && prev?.[k] ? (prev[k] as GrantMap[string]) : v
211 return out
212}
213
214/** The members whose switches in the file differ from what the mod last wrote, with the rights in words. */
215export function grantChanges(recorded: GrantMap, file: GrantMap): { key: string; name: string; changes: string[] }[] {
216 const out: { key: string; name: string; changes: string[] }[] = []
217 for (const [k, v] of Object.entries(file)) {
218 const r = recorded[k]
219 if (!r || same(r, v)) continue
220 const changes: string[] = []
221 if (r.write !== v.write) changes.push(`Allow writes ${v.write ? 'on' : 'off'}`)
222 if (r.agent !== v.agent) changes.push(`Allow subagents ${v.agent ? 'on' : 'off'}`)
223 out.push({ key: k, name: k.slice(k.indexOf('|') + 1), changes })
224 }
225 return out
226}
227hooks/housekeeping.ts 88 lines1// Housekeeping by role: leftover shells and runtimes (bash, sh, node, python, conhost) clog the PC with
2// micro-stutters, so every team member cleans up after itself and every head checks on its reports.
3//
4// - A worker that sends a message to its own boss is reminded to close what it started and delete its scratch.
5// - A head that hears from one of its reports is reminded to scan for that worker's leftovers and tell it to
6// clean up. A head never kills another session's processes itself, and nobody kills by name machine-wide.
7// - A report that is only a cleanup confirmation ("clean") does not trigger the head note again, so a head and a
8// worker never ping-pong confirmations.
9// - Browser-automation MCP servers (Playwright, Chrome DevTools) start in every session and idle for hours: a team
10// of fourteen sessions carried about 140 such processes. Members stop their own unless they are using them.
11//
12// Pure rules from the roster alone (no $), so a test can call them.
13
14import type { Member } from '../types'
15
16const BROWSER = (win: boolean) =>
17 `Browser-automation servers (${win ? 'node.exe' : 'node'} running playwright/mcp or chrome-devtools-mcp${win ? ', and their cmd.exe wrappers' : ''}) ` +
18 `under your own ${win ? 'claude.exe' : 'claude process'}: if you are not using browser tools, stop them; if you used them, stop them as soon as ` +
19 'the browser task is done.'
20
21// the process names a member looks for, per platform
22const PROCS = (win: boolean) =>
23 win ? 'bash.exe, sh.exe, node.exe, python*.exe, conhost.exe' : 'bash, zsh, sh, node, python, deno, bun'
24const OWNER = (win: boolean) => (win ? 'claude.exe' : 'claude process')
25
26export const workerNote = (win = true) =>
27 'HOUSEKEEPING (Team Orchestrator, mandatory): you just reported to your boss. Now close every process YOUR ' +
28 `session started (${PROCS(win)}; trace them to your own ${OWNER(win)}), ` +
29 'delete your scratch and temporary files (deletes inside your own session\'s temporary folder are approved ' +
30 'automatically), and tell your boss "clean" (that word is recorded in your status file on this PC, ' +
31 '~/.claude/team-orchestrator/<project>/status/<your name>.json, with a count of your leftover processes). ' +
32 BROWSER(win) +
33 ' Touch only processes your own session started.'
34export const WORKER_NOTE = workerNote(true)
35
36export const HEAD_NOTE = (worker: string, win = true) =>
37 `HOUSEKEEPING (Team Orchestrator, mandatory): ${worker} just reported to you. Before going on, scan for ` +
38 `leftover processes from ${worker}'s session (${PROCS(win)} owned by ` +
39 `its ${OWNER(win)}, or orphans whose owner is gone), including idle browser-automation servers (playwright/mcp, ` +
40 `chrome-devtools-mcp) it is not using (its status file on this PC, under ~/.claude/team-orchestrator/, shows its last "clean" ` +
41 `and leftover count), and tell ${worker} by SendMessage to close the processes it started and ` +
42 'delete its scratch and /tmp files, then confirm "clean". Do not kill another session\'s processes yourself, and ' +
43 'never kill by name machine-wide.'
44
45const names = (m: Member) => [m.address, m.name].filter((x): x is string => !!x).map(x => x.toLowerCase())
46
47// SendMessage's "to" may carry a " [ref]" suffix: "Data-and-Numbers-Lead [8005a7]".
48const bareTo = (to: string) => to.replace(/\s*\[[^\]]*\]\s*$/, '').trim().toLowerCase()
49
50/** The note for a member's SendMessage, or undefined: only a message to that member's own boss counts. */
51export function onSend(me: Member, list: Member[], to: string, win = true): string | undefined {
52 if (me.boss === 'user') return undefined
53 const boss = list.find(m => m.name === me.boss && m.team === me.team) ?? list.find(m => m.name === me.boss)
54 return boss && names(boss).includes(bareTo(to)) ? workerNote(win) : undefined
55}
56
57/** The sender name a peer delivery carries (from-name="…"), if any. */
58export const senderOf = (text: string) => text.match(/from-name="([^"]+)"/)?.[1]
59
60/** The message body inside a peer delivery's wrapper, or the whole text when there is no wrapper. */
61const bodyOf = (text: string) => (text.match(/<cross-session-message[^>]*>([\s\S]*?)<\/cross-session-message>/)?.[1] ?? text).trim()
62
63/**
64 * A short reply whose point is that the sender cleaned up ("clean.", "clean. Scratch deleted, no processes left."),
65 * not a work report. Longer messages that merely mention cleanup are still reports.
66 */
67export const isCleanConfirmation = (text: string) => {
68 // the body may start with a short speaker label: "Data-and-Numbers-Lead: clean …", "Report lead: clean …"
69 const body = bodyOf(text)
70 const unlabelled = body.replace(/^[A-Za-z][\w -]{0,38}:\s*/, '')
71 const starts = (s: string) => /^\W*(all\s+)?clean\b/i.test(s)
72 return body.length <= 800 && (starts(body) || starts(unlabelled))
73}
74
75/**
76 * Whether a session polls Orca for the roster: the team's top member (boss "user") does, and so does a session that
77 * is not on the roster (the person's own). Every other member only reads the roster file the poller shares.
78 */
79export const shouldPoll = (me: Member | undefined) => !me || me.boss === 'user'
80
81/** The note for a peer message a member receives, or undefined: only a report from one of its own reports counts. */
82export function onReceive(me: Member, list: Member[], text: string, win = true): string | undefined {
83 const from = senderOf(text)
84 if (!from || isCleanConfirmation(text)) return undefined
85 const worker = list.find(m => m !== me && m.boss === me.name && names(m).includes(from.toLowerCase()))
86 return worker ? HEAD_NOTE(worker.name, win) : undefined
87}
88hooks/identity.ts 224 lines1// Who this session is, on the roster: the identity ladder of #57. Pure rules from the roster and this session's facts
2// (no $), so a test can call them.
3//
4// Three facts are checked against each member: the tab (this session's ORCA_TERMINAL_HANDLE against the member's
5// handle), the session id and the name (the registry's name, else the transcript title).
6//
7// | facts that match | treated as | roster update |
8// | tab + id + name | X, full | none |
9// | tab + id (new name) | X, full | follow and relabel (name, address, bosses) |
10// | tab + name (new id) | X, full | sessionId = the new id |
11// | id + name, other tab | X if X's tab is gone, else held | handle = this tab |
12// | tab only | X, full, re-attached | id and name taken; the role-file note is sent |
13// | id only, or name only | held (restricted) | none |
14// | nothing | not on the team | none |
15//
16// No tab handle at all (a session outside Orca) is "unknown", not a mismatch. The machine's session registry
17// (~/.claude/sessions/<pid>.json) vouches for it in place of the tab when it lists this session's id in a folder under
18// the project root: it is then on this machine. That proof stands in for the tab only next to an id or a name, never
19// alone, and never while the member it points at is live somewhere else (that would be a second copy).
20//
21// A member whose home is another PC (0.5.12, #64) is never matched by tab or registry here: handles and the registry
22// are valid only on their own machine. An id and a name alone then hold the session for the user to decide, like any other doubt.
23// A session confirmed as a member with no machine recorded takes this one.
24
25import type { Member } from '../types'
26import { AGENT_TOOL, KEYWORDS, WRITE_TOOLS } from './guard'
27import type { Grants, Verdict } from './guard'
28import { roleFile } from './status'
29import { isAway } from './changes'
30
31export type Level = 'full' | 'restricted' | 'none'
32export type Row = 'tab+id+name' | 'tab+id' | 'tab+name' | 'id+name' | 'tab' | 'id' | 'name' | 'none'
33
34/** This session's own facts. */
35export type Facts = {
36 /** its Orca tab handle (term_…); '' outside Orca, which is unknown, not a mismatch */
37 tab: string
38 sessionId: string
39 /** the session's name from the registry, else its transcript title; '' when it has none */
40 name: string
41}
42
43export type Identity = {
44 level: Level
45 /** the member this session is (full) or looks like (restricted), as it stands after the updates */
46 member?: Member
47 row: Row
48 /** the roster after the updates (the same array when nothing changed) */
49 list: Member[]
50 changed: boolean
51 /** set when the member was relabelled to the session's new name: its old name */
52 renamedFrom?: string
53 /** a fresh session in the member's tab, taken on as the member: it gets the role-file note once */
54 reattached: boolean
55 /** why a restricted session is held, in a sentence; '' otherwise */
56 why: string
57}
58
59export const keyOf = (m: Member) => `${m.team}|${m.name}`
60const namesOf = (m: Member) => [m.address, m.name].filter((x): x is string => !!x)
61
62// the rows in the order the ladder tries them; the first four and "tab" are full, the rest held
63const RANK: Row[] = ['tab+id+name', 'tab+id', 'tab+name', 'id+name', 'tab', 'id', 'name']
64
65/** A name some other member of the project already goes by. */
66export const nameTaken = (list: Member[], name: string, except?: Member) =>
67 list.some(m => m !== except && namesOf(m).some(n => n.toLowerCase() === name.toLowerCase()))
68
69/**
70 * The member (by team|name) renamed to `to`: its name and address take the new name, and the members that report to
71 * it follow. A boss is matched in the member's own team first, so another team's member of the same name is untouched.
72 */
73export function relabel(list: Member[], key: string, to: string): Member[] {
74 const x = list.find(m => keyOf(m) === key)
75 if (!x || to === '' || to === x.name) return list
76 const reportsTo = (m: Member) => m.boss === x.name && (m.team === x.team || !list.some(o => o !== x && o.team === m.team && o.name === x.name))
77 return list.map(m => (m === x ? { ...m, name: to, address: to } : reportsTo(m) ? { ...m, boss: to } : m))
78}
79
80/**
81 * Who this session is.
82 * - list: the roster
83 * - facts: this session's tab (or ''), id and name
84 * - local: the session registry lists this session's id in a folder under the project root (same machine)
85 * - live: the members (team|name) that are running somewhere else right now: a live tab, or another live session
86 * with their id
87 */
88export function identify(args: { list: Member[]; facts: Facts; local: boolean; live: ReadonlySet<string>; here?: string }): Identity {
89 const { list, facts, local, live } = args
90 const here = args.here ?? ''
91 const away = (m: Member) => isAway(m, here)
92 const none: Identity = { level: 'none', row: 'none', list, changed: false, reattached: false, why: '' }
93 if (list.length === 0) return none
94 const tabKnown = facts.tab !== ''
95 const rowOf = (m: Member): Row => {
96 const id = facts.sessionId !== '' && m.sessionId === facts.sessionId
97 const name = facts.name !== '' && namesOf(m).includes(facts.name)
98 // the tab: a real match or mismatch where both sides have one; else the registry's proof, which stands in for
99 // the tab only beside an id or a name, and only while the member is not live elsewhere
100 const both = tabKnown && m.handle !== ''
101 const tab = away(m) ? false : both ? m.handle === facts.tab : local && !live.has(keyOf(m)) && (id || name)
102 if (tab && id && name) return 'tab+id+name'
103 if (tab && id) return 'tab+id'
104 if (tab && name) return 'tab+name'
105 if (id && name) return 'id+name'
106 if (tab) return 'tab'
107 if (id) return 'id'
108 if (name) return 'name'
109 return 'none'
110 }
111 const rows = list.map(m => ({ m, row: rowOf(m) })).filter(r => r.row !== 'none')
112 if (rows.length === 0) return none
113 const best = Math.min(...rows.map(r => RANK.indexOf(r.row)))
114 const row = RANK[best] as Row
115 const top = rows.filter(r => r.row === row)
116 const x = (top[0] as { m: Member }).m
117 const held = (why: string): Identity => ({ level: 'restricted', member: x, row, list, changed: false, reattached: false, why })
118 // two members fit equally well (two of the same name, say): which one is unknown, so neither is granted
119 if (top.length > 1) return held(`it fits ${top.map(r => r.m.name).join(' and ')} equally well`)
120 const elsewhere = (why: string) => (tabKnown ? why : `${why}, and this session has no Orca tab`)
121 if (row === 'id+name') {
122 if (away(x)) return held(`its home is ${x.machine}, so this may be a second copy of the session running there`)
123 if (!tabKnown && !local) return held('it is not in this machine\'s session registry, so it may be running on another device')
124 if (live.has(keyOf(x))) return held(`${x.name}'s own tab is still open, so this looks like a second copy`)
125 }
126 if (row === 'id') return held(elsewhere(`only its session id matches ${x.name}; its tab and its name do not`))
127 if (row === 'name') return held(elsewhere(`only its name matches ${x.name}; its tab and its session id do not`))
128 // full from here: the roster follows the session
129 let next = list
130 const set = (patch: Partial<Member>) => {
131 next = next.map(m => (keyOf(m) === keyOf(x) ? { ...m, ...patch } : m))
132 }
133 if (row === 'tab+name' || row === 'tab') set({ sessionId: facts.sessionId })
134 // its home is this machine, recorded when the roster has none for it (a member from another PC never gets this far)
135 if (here !== '' && !x.machine) set({ machine: here })
136 if (row === 'id+name' && tabKnown) {
137 set({ handle: facts.tab })
138 // a member still recorded on this tab is not in it any more: a close of that member must not close this one
139 next = next.map(m => (keyOf(m) !== keyOf(x) && m.handle === facts.tab ? { ...m, handle: '' } : m))
140 }
141 // a new name (a /rename, or a fresh session's own name) is followed unless another member already goes by it
142 let renamedFrom: string | undefined
143 const wantsName = (row === 'tab+id' || row === 'tab') && facts.name !== '' && !namesOf(x).includes(facts.name)
144 if (wantsName && !nameTaken(list, facts.name, x)) {
145 next = relabel(next, keyOf(x), facts.name)
146 renamedFrom = x.name
147 }
148 const key = renamedFrom === undefined ? keyOf(x) : `${x.team}|${facts.name}`
149 const member = next.find(m => keyOf(m) === key) as Member
150 return { level: 'full', member, row, list: next, changed: next !== list, renamedFrom, reattached: row === 'tab', why: '' }
151}
152
153/** The one-time note a re-attached session gets on its next prompt. */
154export const roleNote = (m: Member) =>
155 `You are ${m.name} in team ${m.team}. Your role file is .claude/team-orchestrator/${roleFile(m.name)}; read it now.`
156
157/** The one-time note a held session gets on its next prompt. */
158export const holdNote = (m: Member, why: string) =>
159 `TEAM ORCHESTRATOR: this session is ON HOLD. It looks like ${m.name} of team ${m.team}, but that is not confirmed (${why}). ` +
160 'Until the user decides, it may not write files, use subagents or use any team tool. The team top has been asked to check with the user. ' +
161 `Do not act as ${m.name} meanwhile.`
162
163const tag = (m: Member, why: string) => `${m.name} of team ${m.team} (${why})`
164
165/** The sentence a team tool answers a held session with. */
166export const holdTool = (tool: string, m: Member, why: string) =>
167 `${tool} is on hold: this session looks like ${tag(m, why)} but is not confirmed. Team tools stay off until the user decides whether it is ${m.name}; the team top has been asked.`
168
169/** The strictest guard, for a held session: Agent, Write, Edit and NotebookEdit only with the person's one-turn word. */
170export function judgeHeld(args: { me: Member; why: string; tool: string; grants: Grants }): Verdict {
171 const { me, why, tool, grants } = args
172 const isAgent = tool === AGENT_TOOL
173 if (!isAgent && !(WRITE_TOOLS as readonly string[]).includes(tool)) return undefined
174 const word = isAgent ? KEYWORDS.agent : KEYWORDS.write
175 if (isAgent ? grants.agent : grants.write) return { kind: 'allow', line: `allowed ${tool} on hold (${word}, this turn only)` }
176 return {
177 kind: 'deny',
178 reason:
179 `Blocked ${tool}: this session is on hold. It looks like ${tag(me, why)} but is not confirmed, so it may not ` +
180 `${isAgent ? 'use subagents' : 'write files'} until the user decides (${word} in the user's next message allows one turn).`,
181 line: `blocked ${tool}: session on hold (looks like ${me.name})`,
182 }
183}
184
185/** The team top above a member: up the boss line to the one that reports to the user. */
186export function topOf(list: Member[], x: Member): Member | undefined {
187 let cur: Member | undefined = x
188 const seen = new Set<string>()
189 while (cur && cur.boss !== 'user' && !seen.has(keyOf(cur))) {
190 seen.add(keyOf(cur))
191 const c: Member = cur
192 cur = list.find(m => m.team === c.team && m.name === c.boss) ?? list.find(m => m.name === c.boss)
193 }
194 return cur && cur.boss === 'user' ? cur : undefined
195}
196
197/** A pending identity question: a held session and the member it looks like. */
198export type Claim = { sessionId: string; member: string; team: string; tab: string; name: string; why: string; at: number; machine?: string; decision?: 'is' | 'new' | 'reject' }
199
200/** The warning X's head gets once. */
201export const headWarning = (c: Claim) =>
202 `IDENTITY WARNING (Team Orchestrator): a session (id ${c.sessionId}, name "${c.name || 'none'}", tab ${c.tab || 'none'}) looks like your report ${c.member} ` +
203 `of team ${c.team}, but it is not confirmed (${c.why}). It is on hold: no writes, no subagents, no team tools. Do not give it work until the user decides.`
204
205/** What the team top is told: ask the user at once, then apply the answer with member_claim. */
206export const topAsk = (c: Claim, boss: string) =>
207 `IDENTITY CHECK (Team Orchestrator), act now: a session (id ${c.sessionId}, name "${c.name || 'none'}", tab ${c.tab || 'none'}) looks like ${c.member} ` +
208 `of team ${c.team}, but it is not confirmed (${c.why}). It is on hold. Ask the user AT ONCE with AskUserQuestion, with these three options: ` +
209 `(1) "This is ${c.member}": the roster takes its id, tab and name. ` +
210 `(2) "New member under ${boss}": it joins as a worker with its own role file. ` +
211 '(3) "Reject": it stays on hold, off the team. ' +
212 `Then apply the answer with member_claim { sessionId: "${c.sessionId}", decision: "is" | "new" | "reject", member: "${c.member}" }.`
213
214/** A name for a new member that nobody on the roster goes by: the wanted one, else the base with a number. */
215export function freshName(list: Member[], wanted: string, base: string): string {
216 const clean = (s: string) => s.replace(/[^A-Za-z0-9_ -]/g, '-').trim().slice(0, 40)
217 const w = clean(wanted)
218 if (w !== '' && !nameTaken(list, w)) return w
219 for (let i = 2; ; i++) {
220 const n = `${clean(base)}-${i}`
221 if (!nameTaken(list, n)) return n
222 }
223}
224hooks/status.ts 250 lines1// Event-driven member status. Each session writes only its own small status file (status/<name>.json inside
2// .claude/team-orchestrator/), on turn start and end, when it asks the person something, and on a 60 s heartbeat while
3// it works. Nobody reads other members' terminal screens, so Orca is not asked about every member on every refresh.
4//
5// The team top also decides, from these files alone, which idle workers to close and whether a message to a closed
6// worker can reopen it now or must wait (the session cap). Pure rules from data (no $), so a test can call them.
7
8import type { Member } from '../types'
9import { isAway } from './changes'
10
11export type Status = {
12 name: string
13 sessionId: string
14 /** working | idle | asking | offline | closed */
15 state: string
16 turnStart?: number
17 turnEnd?: number
18 heartbeat: number
19 model?: string
20 effort?: string
21 /** context used, percent */
22 ctx?: number
23 /** the session's live context window in tokens, from $.session.usage().context.window (0.5.13, #63) */
24 window?: number
25 /** what the member is on now: the Clean View step, else the first line of the last order from its boss */
26 task?: string
27 /** when it last told its boss "clean" */
28 lastClean?: number
29 /** its own leftover shells and runtimes at the last count, -1 when the count could not be made */
30 leftover?: number
31 countedAt?: number
32}
33
34/** Team-wide settings for worker auto-close, kept in .claude/team-orchestrator/settings.json. */
35export type TeamSettings = {
36 autoClose: boolean
37 /** minutes idle after "clean" before a worker is closed */
38 idleMinutes: number
39 /** members never closed, besides anyone with reports */
40 exempt: string[]
41 /** reopen with claude --resume (keeps context) or fresh (briefed again) */
42 reopen: 'resume' | 'fresh'
43 /** most sessions open at once; 0 = no cap */
44 maxOpen: number
45 /** at Create: start only the top and the team heads under it (the rest on their first message), or everyone */
46 launch: 'demand' | 'all'
47 /** sessions started at once at Create; the next batch waits until these are ready and briefed */
48 batch: number
49 /** deletes of a member's own scratch are approved without asking (platform.ts) */
50 autoScratch: boolean
51 /** the project's scratch folder, relative to the project; heads and leads may clean it without asking */
52 scratchDir: string
53}
54
55export const TEAM_SETTINGS0: TeamSettings = { autoClose: true, idleMinutes: 10, exempt: [], reopen: 'resume', maxOpen: 8, launch: 'demand', batch: 3, autoScratch: true, scratchDir: '.claude/scratch' }
56
57export const MIN = 60_000
58/** a member with no write for this long shows as offline */
59export const STALE_MS = 5 * MIN
60/** the tab check runs only when some heartbeat is older than this */
61export const CHECK_AFTER_MS = 2 * MIN
62/** at most one leftover-process count per member per this long */
63export const COUNT_EVERY_MS = 5 * MIN
64
65/** A file-safe name for a member's status file. */
66export const statusFile = (name: string) => `status/${name.replace(/[^\p{L}\p{N}._-]+/gu, '_')}.json`
67
68/** A member's role file, beside its status file: roles/<name>.md (read by the member through its start-up pointer). */
69export const roleFile = (name: string) => `roles/${name.replace(/[^\p{L}\p{N}._-]+/gu, '_')}.md`
70
71// ── Sleep-aware clocks (0.5.16, #71) ──
72// A session's 30 s round notes the time of each tick. A gap more than three minutes beyond the interval is time the
73// machine slept (or the session hung): it is kept as a sleep window for a day, and every age below (silent, idle) skips
74// the time spent asleep. Waking up closes nothing, and a hung top only delays auto-close, never brings it forward.
75
76/** a time the machine slept, from..to (ms) */
77export type Sleep = { from: number; to: number }
78/** a tick this much later than expected counts as sleep */
79export const SLEEP_SLACK_MS = 3 * MIN
80/** sleep windows are kept this long */
81export const SLEEP_KEEP_MS = 24 * 60 * MIN
82/** a member silent for longer than this is checked (is its process alive?) before a message is sent to it */
83export const CRASH_CHECK_MS = 90_000
84
85/** The sleep windows after a tick at now, the previous one at prev (0: none yet), the ticks every ms apart. */
86export function noteTick(prev: number, now: number, every: number, sleeps: readonly Sleep[]): Sleep[] {
87 const kept = sleeps.filter(w => now - w.to <= SLEEP_KEEP_MS)
88 return prev > 0 && now - prev > every + SLEEP_SLACK_MS ? [...kept, { from: prev + every, to: now }] : kept
89}
90
91/** How much of from..to was spent asleep. */
92export const asleepWithin = (sleeps: readonly Sleep[], from: number, to: number) =>
93 sleeps.reduce((n, w) => n + Math.max(0, Math.min(to, w.to) - Math.max(from, w.from)), 0)
94
95/** The age of a time stamp, the time spent asleep since then left out. */
96export const awakeAge = (since: number, now: number, sleeps: readonly Sleep[] = []) => Math.max(0, now - since - asleepWithin(sleeps, since, now))
97
98/** The state to show: a silent member is offline; a closed one stays closed. Time asleep is not silence. */
99export const shownState = (s: Status | undefined, now: number, sleeps: readonly Sleep[] = []) =>
100 !s ? undefined : s.state === 'closed' ? 'closed' : awakeAge(s.heartbeat, now, sleeps) > STALE_MS ? 'offline' : s.state
101
102/** The member has been silent long enough that a message to it first checks whether its session is alive (#71). */
103export const heartbeatStale = (s: Status, now: number, sleeps: readonly Sleep[] = []) => s.state !== 'closed' && awakeAge(s.heartbeat, now, sleeps) > CRASH_CHECK_MS
104
105/** Whether the tab check is worth an Orca call: some open member has been silent for over two minutes. */
106export const needsTabCheck = (statuses: (Status | undefined)[], now: number, sleeps: readonly Sleep[] = []) =>
107 statuses.some(s => s && s.state !== 'closed' && awakeAge(s.heartbeat, now, sleeps) > CHECK_AFTER_MS)
108
109/** The next tab-check interval: doubled while Orca answers slowly (over 500 ms), back to the base when it is fast. */
110export const nextCheckInterval = (current: number, tookMs: number, base = 2 * MIN, cap = 10 * MIN) =>
111 tookMs > 500 ? Math.min(cap, Math.max(base, current) * 2) : base
112
113const hasReports = (m: Member, list: Member[]) => list.some(x => x !== m && x.boss === m.name)
114
115/** When a member went idle: after its last turn end or its last "clean", whichever is later. */
116const idleSince = (s: Status) => Math.max(s.turnEnd ?? 0, s.lastClean ?? 0)
117
118/** Workers that may be closed: no reports, not exempt, idle, said "clean", and idle for the set minutes (awake). */
119export function toClose(list: Member[], statuses: Map<string, Status>, t: TeamSettings, now: number, sleeps: readonly Sleep[] = []): Member[] {
120 if (!t.autoClose) return []
121 return list.filter(m => {
122 const s = statuses.get(m.name)
123 return (
124 !!s &&
125 !hasReports(m, list) &&
126 !t.exempt.includes(m.name) &&
127 shownState(s, now, sleeps) === 'idle' &&
128 !!s.lastClean &&
129 awakeAge(idleSince(s), now, sleeps) >= t.idleMinutes * MIN
130 )
131 })
132}
133
134/** How many sessions are open: members whose state is neither offline nor closed. */
135export const openCount = (statuses: Map<string, Status>, now: number, sleeps: readonly Sleep[] = []) =>
136 [...statuses.values()].filter(s => !['offline', 'closed'].includes(shownState(s, now, sleeps) ?? 'offline')).length
137
138export type Admit = { kind: 'open' } | { kind: 'evict'; name: string } | { kind: 'queue' }
139
140/**
141 * Whether a closed member can reopen now: under the cap it opens; at the cap the longest-idle closable worker makes
142 * room; with no worker to close, the message waits in the queue.
143 */
144export function admit(list: Member[], statuses: Map<string, Status>, t: TeamSettings, now: number, sleeps: readonly Sleep[] = []): Admit {
145 if (t.maxOpen <= 0 || openCount(statuses, now, sleeps) < t.maxOpen) return { kind: 'open' }
146 const idle = list
147 .filter(m => !hasReports(m, list) && !t.exempt.includes(m.name) && shownState(statuses.get(m.name), now, sleeps) === 'idle')
148 .sort((a, b) => idleSince(statuses.get(a.name)!) - idleSince(statuses.get(b.name)!))
149 return idle.length ? { kind: 'evict', name: idle[0]!.name } : { kind: 'queue' }
150}
151
152/** One short line for the task field. */
153export const taskLine = (text: string) => text.trim().split(/\r?\n/)[0]!.slice(0, 120)
154
155/**
156 * Who starts at Create when the rest start on demand: the top (boss "user") and, under a CEO, each team's head (a
157 * report of the top in another team). Leads and workers start the first time someone messages them.
158 */
159export const startsAtCreate = (m: Member, list: Member[]) =>
160 m.boss === 'user' || list.some(t => t.boss === 'user' && t.name === m.boss && t.team !== m.team)
161
162/** xs in groups of n (n below 1 counts as 1). */
163export const chunk = <T>(xs: T[], n: number): T[][] => {
164 const size = Math.max(1, Math.floor(n) || 1)
165 const out: T[][] = []
166 for (let i = 0; i < xs.length; i += size) out.push(xs.slice(i, i + size))
167 return out
168}
169
170/**
171 * The "name [ref]" of the session on this machine named name, from a ListAgents listing, or '' when there is none.
172 * Remote Control mirrors of old sessions often share a member's name, and then the bare name is ambiguous.
173 */
174export const localRef = (listing: string, name: string) => {
175 for (const line of listing.split(/\r?\n/)) {
176 const m = line.match(/^\s*(.+?) \[([0-9a-f]+)\]\s+·\s+interactive\b/)
177 if (m && m[1] === name) return `${name} [${m[2]}]`
178 }
179 return ''
180}
181
182/**
183 * A model name as claude --model accepts it (0.5.13, #63). The roster may keep the old short name shown on screen
184 * ("haiku-5-5", or "Haiku 5.5" from a status line), which the API rejects: only that form (opus, sonnet, haiku or fable
185 * followed by digits) gets the "claude-" prefix back. The aliases stay. Any other name (a full id with or without [1m],
186 * a gateway's model) goes through as typed, so a wrong one fails visibly at start. "default", "keep" and "" give no
187 * --model, and so does a name a shell would read as more than one word.
188 */
189export function modelArg(model: string): string {
190 const raw = model.trim()
191 const s = raw.toLowerCase().replace(/\s+/g, '-').replace(/(\d)\.(\d)/g, '$1-$2')
192 if (s === '' || s === 'default' || s === 'keep') return ''
193 if (/^(opus|sonnet|haiku|fable)-\d[\w.-]*(\[1m\])?$/.test(s)) return `claude-${s}`
194 if (/^(opus|sonnet|haiku|fable|best|opusplan)(\[1m\])?$/.test(s)) return s
195 return /^[A-Za-z0-9._:/@+-]+(\[1m\])?$/i.test(raw) ? raw : ''
196}
197
198/** A model id as the roster shows it: the "claude-" prefix cut, the rest as typed. */
199export const shownModel = (model: string) => model.replace(/^claude-/i, '')
200
201/**
202 * The context window to count a transcript's tokens against when the member has not reported its own (#63): the
203 * window its status file records, else 1M for an id with [1m] or a session already past 200k, else 200k.
204 */
205export const windowFor = (model: string, used: number, reported?: number) =>
206 reported && reported > 0 ? reported : /\[1m\]$/i.test(model) || used > 200000 ? 1000000 : 200000
207
208// ── Where a member runs, and which CLI (0.5.13, #67 and #69) ──
209
210/** The CLIs a tab can run; anything else is "other". */
211export const CLIS = ['claude', 'codex', 'hermes', 'gemini', 'opencode', 'qwen'] as const
212
213/**
214 * The CLI an Orca tab runs: Orca's own agentIdentity when it gives one, else the one CLI its command line or screen
215 * names. '' when unknown (several CLIs named, or none): the member is then treated as Claude, as before.
216 */
217export function cliOf(tab: { agentIdentity?: unknown; command?: unknown; preview?: unknown } | undefined): string {
218 if (!tab) return ''
219 const id = typeof tab.agentIdentity === 'string' ? tab.agentIdentity.trim().toLowerCase() : ''
220 if (id !== '') return CLIS.find(c => id.includes(c)) ?? 'other'
221 const text = [tab.command, tab.preview].filter((x): x is string => typeof x === 'string').join('\n').toLowerCase()
222 const named = new Set([...text.matchAll(/(?:^|[\s>"'\\/])(claude|codex|hermes|gemini|opencode|qwen)(?:\.exe|\.cmd|\.ps1)?(?=["'\s]|$)/gm)].map(x => x[1] as string))
223 return named.size === 1 ? ([...named][0] as string) : ''
224}
225
226/** A member the mod runs: a Claude Code session. One adopted from another CLI is "not managed" (#69). */
227export const isManaged = (m: Pick<Member, 'cli'>) => !m.cli || m.cli === 'claude'
228
229export type Location = 'local' | 'remote' | 'other-cli'
230
231/**
232 * How a message reaches a member from this machine (#67): local (send by session id), remote (another PC, or recorded
233 * remote: the messenger route of #72) or other-cli (not messaged).
234 */
235export const locationOf = (m: Member, here: string): Location =>
236 !isManaged(m) ? 'other-cli' : isAway(m, here) || m.location === 'remote' ? 'remote' : 'local'
237
238// ── Orca workspaces (0.5.13, #68) ──
239
240/** The folder in an Orca worktree id ("<repo>::<folder>"), '' when the id carries none. */
241export const pathInWorktreeId = (id: string) => {
242 const cut = id.indexOf('::')
243 return cut < 0 ? '' : id.slice(cut + 2).trim()
244}
245
246const slashed = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
247
248/** The workspace folder contains the project root (or is it). */
249export const worktreeHolds = (folder: string, root: string) => folder.trim() !== '' && `${slashed(root)}/`.startsWith(`${slashed(folder)}/`)
250hooks/platform.ts 255 lines1// Windows, macOS and Linux (0.5.06). Pure rules, so a test can fake each platform.
2//
3// - Which deletes the mod may approve on a member's behalf, so routine clean-up never waits for the person.
4// Workers: only inside their own session folder under the Claude temp folder. Heads and leads: also anywhere in the
5// system temp folder and in the project's scratch folder (default .claude/scratch). Anything else still asks.
6// - The leftover-process count from `ps` output on macOS and Linux (Windows keeps its PowerShell count).
7
8/** Forward slashes, a drive letter in Git Bash form (/c/...) as c:/..., no trailing slash; the case kept. */
9export const keepPath = (p: string) =>
10 p
11 .replace(/\\/g, '/')
12 .replace(/^\/([a-zA-Z])\//, '$1:/')
13 .replace(/\/+$/, '')
14
15/** keepPath, lower case for comparing (the same length, so a prefix found here cuts keepPath too). */
16export const normPath = (p: string) => keepPath(p).toLowerCase()
17
18const isAbs = (p: string) => /^([a-z]:\/|\/)/.test(p)
19const under = (p: string, root: string) => root !== '' && (p === root || p.startsWith(`${root}/`))
20
21/** Shell words, with single and double quotes removed; undefined when the line is more than one plain command. */
22export function words(command: string): string[] | undefined {
23 // a pipe, chain, redirect, substitution or variable makes the targets unknowable: never approve those
24 if (/[;&|<>`\n\r]|\$\(|\$\{|\$[A-Za-z_]/.test(command)) return undefined
25 const out: string[] = []
26 for (const m of command.trim().matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)) out.push(m[1] ?? m[2] ?? m[3] ?? '')
27 return out
28}
29
30export type ScratchContext = {
31 /** the session's working folder, for relative paths */
32 cwd: string
33 sessionId: string
34 /** the member has reports (a head or a lead) */
35 head: boolean
36 /** the system temp folder(s): TEMP / TMP / TMPDIR, and /tmp */
37 temps: string[]
38 /** the project's scratch folder, absolute; '' for none */
39 projectScratch: string
40}
41
42const DELETE = /^(rm|rmdir|del|erase|rd|remove-item|ri)$/i
43// options that take a value: Remove-Item's -Path / -LiteralPath name the target, the rest are skipped with their value
44const VALUE_OPTS = /^-(path|literalpath|filter|include|exclude)$/i
45
46/** One target of an approvable delete: the allowed root it sits under and the path, both absolute with the case kept. */
47export type ScratchTarget = { root: string; path: string }
48/** An approvable delete: its targets, and whether it deletes folders with what is in them. */
49export type ScratchPlan = { targets: ScratchTarget[]; recursive: boolean }
50
51/**
52 * The delete, when a Bash or PowerShell command is only a delete of paths the member may clean up without asking;
53 * undefined otherwise. Only plain literal paths (0.5.11, #60): no wildcard (* ? [ ]) and no trailing slash, since
54 * either can carry the shell through a link into real files. Links themselves are checked by linkFree, on the disk.
55 */
56export function scratchDeletePlan(command: string, c: ScratchContext): ScratchPlan | undefined {
57 const w = words(command)
58 if (!w || w.length < 2 || !DELETE.test(w[0] as string)) return undefined
59 const raws: string[] = []
60 let recursive = false
61 for (let i = 1; i < w.length; i++) {
62 const t = w[i] as string
63 if (/^-(path|literalpath)$/i.test(t)) {
64 if (w[i + 1] !== undefined) raws.push(w[++i] as string)
65 continue
66 }
67 if (VALUE_OPTS.test(t)) {
68 i++
69 continue
70 }
71 // rm -r/-rf/-R/--recursive, Remove-Item -Recurse (or any prefix of it), rd /s, rmdir /s, del /s. Any option with
72 // an r in it counts (-Force too): taking a delete as recursive only adds a check, never an approval.
73 if (/^-/.test(t)) {
74 if (/r/i.test(t)) recursive = true
75 continue
76 }
77 if (/^\/[a-z]$/i.test(t)) {
78 if (/^\/s$/i.test(t)) recursive = true
79 continue
80 }
81 raws.push(t)
82 }
83 if (raws.length === 0) return undefined
84 const cwd = keepPath(c.cwd)
85 const temps = c.temps.filter(Boolean).map(normPath)
86 const claudeTemps = temps.map(t => `${t}/claude`)
87 const scratch = c.projectScratch ? normPath(c.projectScratch) : ''
88 const id = c.sessionId.toLowerCase()
89 const targets: ScratchTarget[] = []
90 for (const raw of raws) {
91 if (raw.startsWith('~') || /[*?[\]]/.test(raw) || /[\\/]$/.test(raw)) return undefined
92 let k = keepPath(raw)
93 if (!isAbs(k.toLowerCase())) k = `${cwd}/${k}`
94 const p = k.toLowerCase()
95 if (p.split('/').some(s => s === '..' || s === '.')) return undefined
96 const roots = [
97 ...(id !== '' && p.includes(id) ? claudeTemps.filter(t => under(p, t) && p !== t) : []),
98 ...(c.head ? temps.filter(t => under(p, t) && p !== t) : []),
99 ...(c.head && scratch !== '' && under(p, scratch) && p !== scratch ? [scratch] : []),
100 ]
101 // the longest allowed root above the path: the walk then checks every folder below it
102 const root = roots.sort((a, b) => b.length - a.length)[0]
103 if (root === undefined) return undefined
104 targets.push({ root: k.slice(0, root.length), path: k })
105 }
106 return { targets, recursive }
107}
108
109/** Whether a Bash or PowerShell command is only a delete of paths the member may clean up without asking (by its words alone). */
110export const scratchDeleteAllowed = (command: string, c: ScratchContext): boolean => scratchDeletePlan(command, c) !== undefined
111
112/** One entry of a folder listing, as $.fs.list gives it. */
113export type ListEntry = { name: string; kind: string; isLink: boolean }
114/** The most entries a recursive delete's folder may hold for the mod to approve it; above it, the person is asked. */
115export const WALK_CAP = 2000
116
117/**
118 * Whether the delete can be approved on the disk as it is now: every folder from the allowed root down to the target,
119 * and the target itself, is a plain folder or file (no symbolic link, no junction, nothing the listing cannot name),
120 * and for a recursive delete nothing inside the target is a link either (older PowerShell follows a junction it finds
121 * inside a folder it deletes). A path that cannot be listed, or a folder over WALK_CAP entries, is not approved.
122 * Hard links are left alone: deleting one removes that name only.
123 */
124export async function linkFree(plan: ScratchPlan, list: (path: string) => Promise<readonly ListEntry[]>): Promise<boolean> {
125 const unsafe = (x: ListEntry) => x.isLink || (x.kind !== 'file' && x.kind !== 'dir')
126 const find = (entries: readonly ListEntry[], name: string) => entries.find(x => x.name === name) ?? entries.find(x => x.name.toLowerCase() === name.toLowerCase())
127 try {
128 for (const t of plan.targets) {
129 const below = t.path.slice(t.root.length + 1).split('/').filter(s => s !== '')
130 let at = t.root
131 let last: ListEntry | undefined
132 for (const seg of below) {
133 last = find(await list(at), seg)
134 if (!last || unsafe(last)) return false
135 at = `${at}/${last.name}`
136 }
137 if (!last) return false
138 if (!plan.recursive || last.kind !== 'dir') continue
139 const queue = [at]
140 let seen = 0
141 while (queue.length > 0) {
142 const dir = queue.shift() as string
143 for (const x of await list(dir)) {
144 if (++seen > WALK_CAP || unsafe(x)) return false
145 if (x.kind === 'dir') queue.push(`${dir}/${x.name}`)
146 }
147 }
148 }
149 return true
150 } catch {
151 return false
152 }
153}
154
155const LEFTOVER = /^(bash|zsh|sh|fish|dash|node|deno|bun|python[\d.]*)$/
156
157/**
158 * Leftover shells and runtimes under the member's own claude process, from `ps -eo pid=,ppid=,args=` output:
159 * -1 when no process carries the session id (the count is unknown, not zero).
160 */
161export function countFromPs(text: string, sessionId: string): number {
162 const procs = text
163 .split(/\r?\n/)
164 .map(l => l.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/))
165 .filter((m): m is RegExpMatchArray => !!m)
166 .map(m => ({ pid: Number(m[1]), ppid: Number(m[2]), args: m[3] as string }))
167 const me = procs.find(p => p.args.includes(sessionId) && /\bclaude\b/.test(p.args))
168 if (!me) return -1
169 const byPid = new Map(procs.map(p => [p.pid, p]))
170 const name = (args: string) => (args.split(/\s+/)[0] ?? '').split('/').pop()!.replace(/^-/, '')
171 let n = 0
172 for (const p of procs) {
173 if (p === me || !LEFTOVER.test(name(p.args))) continue
174 let x = p
175 for (let i = 0; i < 8; i++) {
176 const up = byPid.get(x.ppid)
177 if (!up) break
178 if (up.pid === me.pid) {
179 n++
180 break
181 }
182 x = up
183 }
184 }
185 return n
186}
187
188// ── Is a member's session still running? The send-time crash check (0.5.16, #71 and #65) ──
189// Run only when a message goes to a member that has been silent for over 90 s: never on a timer. The machine's session
190// registry (~/.claude/sessions/<pid>.json) names the process ids that hold the member's session id; each is then looked
191// up by its id alone (one Get-CimInstance query on Windows, `ps -o args= -p <pid>` elsewhere).
192
193/** One process as the check saw it: running or not, and its command line ('' when it cannot be read). */
194export type Proc = { running: boolean; args: string }
195
196/** dead: proved gone; alive: a claude process still holds the session; unsure: it cannot be told (so nothing is reopened). */
197export type Liveness = { kind: 'dead' } | { kind: 'alive'; pid: number } | { kind: 'unsure'; why: string }
198
199/** The PowerShell that looks the given process ids up in one Get-CimInstance query filtered by ProcessId. */
200export const winProcScript = (pids: number[]) => {
201 const ids = pids.filter(p => Number.isInteger(p) && p > 0)
202 return (
203 `$ErrorActionPreference='Stop'; $ps=@(Get-CimInstance Win32_Process -Filter '${ids.map(p => `ProcessId=${p}`).join(' OR ')}'); ` +
204 `foreach($i in @(${ids.join(',')})){ $p=$ps|?{$_.ProcessId -eq $i}|select -First 1; if($p){'RUN|'+$i+'|'+[string]$p.CommandLine}else{'NONE|'+$i} }; 'DONE'`
205 )
206}
207
208/** The processes in winProcScript's output; undefined when the output is not a complete answer for every id. */
209export function parseWinProcs(stdout: string, pids: number[]): Map<number, Proc> | undefined {
210 const lines = stdout.split(/\r?\n/).map(l => l.trim())
211 if (!lines.includes('DONE')) return undefined
212 const out = new Map<number, Proc>()
213 for (const l of lines) {
214 const m = l.match(/^(RUN|NONE)\|(\d+)(?:\|(.*))?$/)
215 if (m) out.set(Number(m[2]), { running: m[1] === 'RUN', args: m[3] ?? '' })
216 }
217 return pids.every(p => out.has(p)) ? out : undefined
218}
219
220/** One process from `ps -o args= -p <pid>`: exit 0 with a line is running, exit 1 with nothing is gone, anything else unknown. */
221export function psProc(r: { exitCode: number; stdout: string } | undefined): Proc | undefined {
222 if (!r) return undefined
223 const text = String(r.stdout ?? '').trim()
224 if (r.exitCode === 0 && text !== '') return { running: true, args: text }
225 if (r.exitCode === 1 && text === '') return { running: false, args: '' }
226 return undefined
227}
228
229/**
230 * Whether the session is alive, from the process ids the registry names for it and what the check found at each.
231 * - a running process whose command line carries the session id: alive
232 * - a running claude process without the id on its command line (a session started as plain `claude`): alive, as
233 * the registry file named after that pid still lists the session
234 * - a running process whose command line cannot be read: unsure
235 * - gone, or now another program (a reused pid): that registry entry is stale and proves nothing
236 * No pid at all, or only stale entries: dead. Any pid without an answer: unsure.
237 */
238export function livenessOf(sessionId: string, pids: number[], procs: ReadonlyMap<number, Proc>): Liveness {
239 const id = sessionId.toLowerCase()
240 let doubt = ''
241 for (const pid of pids) {
242 const p = procs.get(pid)
243 if (!p) {
244 doubt ||= `process ${pid} could not be looked up`
245 continue
246 }
247 if (!p.running) continue
248 const args = p.args.toLowerCase()
249 if (id !== '' && args.includes(id)) return { kind: 'alive', pid }
250 if (args.trim() === '') doubt ||= `process ${pid} is running but its command line cannot be read`
251 else if (/\bclaude\b/.test(args)) return { kind: 'alive', pid }
252 }
253 return doubt ? { kind: 'unsure', why: doubt } : { kind: 'dead' }
254}
255hooks/roles.ts 105 lines1// Role files (0.5.04). Each member's role lives in .claude/team-orchestrator/roles/<name>.md, not in a chat message
2// that a summary can shrink. The session is started with a short pointer in its system prompt (--append-system-prompt):
3// who it is, its boss, the one rule that matters most for its level, and the path of its role file. The file is read
4// once; when the team changes the mod rewrites it and Claude Code tells the session what changed.
5//
6// The file has two parts. Above the marker: generated from the roster, rewritten on every change. Below it: the
7// person's own notes (a voice, a working style, extra rules), never touched by the mod.
8//
9// Pure rules from the roster alone (no $), so a test can call them.
10
11import type { Member } from '../types'
12import { roleFile } from './status'
13
14export const MARKER = '<!-- END OF THE GENERATED PART. The Team Orchestrator rewrites everything above this line when the team changes. Write your own notes below it. -->'
15
16const NOTES =
17 '## Personality and notes\r\n\r\n' +
18 '(Optional. Add a voice, a working style or extra rules for this member here. The Team Orchestrator never changes this part.)\r\n'
19
20const kind = (x: Member, list: Member[]) => (x.boss === 'user' || x.level === 1 ? 'head' : list.some(k => k.boss === x.name) ? 'lead' : 'worker')
21const bossLine = (m: Member) => (m.boss === 'user' ? 'the user (the person at the keyboard)' : m.boss)
22
23/** The generated part of a member's role file. list: the member's team and every boss above it. */
24export function roleText(m: Member, list: Member[]): string {
25 const kids = list.filter(x => x.boss === m.name)
26 const peers = list.filter(x => x.name !== m.name && list.some(k => k.boss === x.name))
27 const manages = kids.length > 0
28 const lines = [
29 `# ${m.name}`,
30 '',
31 `Team: ${m.team}. Role: ${m.role}.`,
32 `Boss: ${bossLine(m)}.`,
33 ...(manages ? [`Direct reports: ${kids.map(k => k.name).join(', ')}.`] : []),
34 '',
35 '## Your job',
36 '',
37 manages
38 ? 'You orchestrate. You decide, plan and instruct; you do not do the tasks yourself. Give the work to your direct reports and review what they send back. Do not go around a lead to instruct someone else\'s worker.'
39 : 'You do the tasks your boss gives you and report the results back to your boss.',
40 '',
41 '## Messages',
42 '',
43 ...(manages
44 ? [
45 '- Message your direct reports with the team_message tool (mcp__team-orchestrator__team_message, { to, message }), not SendMessage. A report may not be running yet, or may have been closed while idle; team_message starts it and then delivers.',
46 `- Message your boss${peers.length ? ` and the other heads and leads (${peers.map(p => p.name).join(', ')})` : ''} with SendMessage.`,
47 ]
48 : ['- Talk only to your boss, with SendMessage. Do not message your boss\'s boss, other leads or other workers unless your boss names one to you.']),
49 '- If SendMessage says a teammate\'s name is ambiguous or unknown, use team_message with the plain name instead.',
50 '- Never type into another member\'s terminal (orca terminal send) to message it.',
51 '',
52 '## Housekeeping',
53 '',
54 'After each task, close every process your session started (bash, sh, node, python, conhost), stop browser-automation servers you are not using, delete your scratch and temporary files, and tell your boss "clean". Touch only what your own session started.',
55 '',
56 '## The team',
57 '',
58 ...list.map(x => `- ${x.name}: ${kind(x, list)}, reports to ${x.boss === 'user' ? 'the user' : x.boss}. ${x.role}`),
59 '',
60 '## Team files',
61 '',
62 'The team lives in .claude/team-orchestrator/. roster.json is the structure and settings.json the team settings; only the team top\'s session writes them, and every other session\'s change waits in changes/ until the top applies it. Never edit roster.json, settings.json, meta.json or changes/ by hand. Each member\'s live status is kept on its own PC, under ~/.claude/team-orchestrator/<project>/status/, written by the Team Orchestrator for its own session; never edit another member\'s. This file is ' + roleFile(m.name) + '.',
63 '',
64 ]
65 return lines.join('\r\n')
66}
67
68/** The whole file: the generated part, the marker, and the notes kept from the file as it was (or the empty notes). */
69export function mergeRole(existing: string | undefined, generated: string): string {
70 const at = existing?.indexOf(MARKER) ?? -1
71 const notes = existing && at >= 0 ? existing.slice(at + MARKER.length).replace(/^\r?\n/, '') : `\r\n${NOTES}`
72 return `${generated}${MARKER}\r\n${notes}`
73}
74
75// a shell types the start command (cmd.exe on Windows, an interactive bash or zsh elsewhere): no double quote (it would
76// end the argument), no % $ ` or backslash (variables and escapes), no ! (history expansion), no line breaks
77const safe = (s: string) => s.replace(/["%$`\\!\r\n]+/g, ' ')
78
79/** The system-prompt pointer a member starts with: short, because it is sent on every turn. */
80export function pointer(m: Member, list: Member[]): string {
81 const manages = list.some(x => x.boss === m.name)
82 const rule = manages
83 ? 'You plan, delegate and review; you do not do the work yourself. Message your direct reports with the team_message tool, not SendMessage.'
84 : 'You do the tasks your boss gives you and report back to your boss only, with SendMessage.'
85 return safe(
86 `You are ${m.name}, a member of the Team Orchestrator team '${m.team}'. Your boss is ${bossLine(m)}. ${rule} ` +
87 `Your full role is in the file .claude/team-orchestrator/${roleFile(m.name)}. Read it before your first action and follow it. ` +
88 'Read it again after any conversation summary, or when you are told it changed.',
89 )
90}
91
92/** The first prompt of a member started at Create: confirm it read its role, then wait. */
93export const WELCOME = 'Read your role file now (its path is in your system prompt). Then reply with Noted and one line that restates your role and your boss, and wait for instructions.'
94
95/** Who a team's members need to know: the team, every boss above it, and the direct reports it has in other teams (a CEO's heads). */
96export function orgOf(all: Member[], team: string): Member[] {
97 const org = new Set(all.filter(m => m.team === team).map(m => m.name))
98 for (let grew = true; grew; ) {
99 grew = false
100 for (const m of all) if (org.has(m.name)) for (const b of all) if (b.name === m.boss && !org.has(b.name)) (org.add(b.name), (grew = true))
101 }
102 for (const m of all) if (m.team === team) for (const k of all) if (k.boss === m.name) org.add(k.name)
103 return all.filter(m => org.has(m.name))
104}
105hooks/changes.ts 233 lines1// Safe roster writes across sessions, versions and machines (0.5.12, #59 and #64). Pure rules from data (no $), so a
2// test can call them.
3//
4// - One writer. Only the team top's session (on the top's machine) writes roster.json and settings.json. Every other
5// session writes a small change file, changes/<ms>-<rand>.json, holding the member fields it changed (or a settings
6// patch). Every session reads the roster as the file plus the change files not yet applied, in time order, field
7// by field; the top folds them into the file and records them in changes/applied.json. The mod's file API cannot
8// delete, so an applied change file stays where it is and the index says it is done; files older than 7 days are
9// ignored by their name alone.
10// - A schema stamp beside the roster (meta.json): schemaVersion, writtenBy (the writer's mod version) and topMachine.
11// roster.json stays a plain array, so older copies of the mod still read it. A session whose mod is older than
12// writtenBy writes only change files, which wait for a current top.
13// - The queue: one file per message, queue/<ms>-<rand>.json, with a state. Delivered and failed entries are kept a day,
14// everything at most 7 days; queue/pruned.json lists the entries the top's round has pruned.
15// - Machines: a member records its home machine; a member whose home is another PC is never matched, reopened, closed
16// or messaged from here.
17
18import type { Member } from '../types'
19import type { TeamSettings } from './status'
20
21export const SCHEMA = 2
22export const MIN_MS = 60_000
23export const DAY = 24 * 60 * MIN_MS
24/** change files and queue entries older than this are ignored by their name alone */
25export const KEEP_MS = 7 * DAY
26/** a queue entry marked sending this long ago was left by a round that stopped: it is tried again */
27export const SENDING_MS = 5 * MIN_MS
28export const MAX_TRIES = 3
29
30/** The member fields kept in the roster file. */
31export const STRUCT = ['team', 'name', 'address', 'role', 'level', 'boss', 'handle', 'sessionId', 'worktree', 'machine', 'short', 'allowAgent', 'allowWrite', 'briefed', 'noted', 'statusFile', 'pending', 'model', 'effort', 'location', 'cli'] as const
32
33export const keyOf = (m: Partial<Member>) => `${m.team ?? ''}|${m.name ?? ''}`
34
35/** A member as the roster file keeps it: its structure fields only. */
36export const project = (m: Partial<Member>): Partial<Member> => {
37 const out: Record<string, unknown> = {}
38 for (const k of STRUCT) if ((m as any)[k] !== undefined) out[k] = (m as any)[k]
39 return out as Partial<Member>
40}
41
42// ── change files ──
43
44export type Op =
45 | { op: 'set'; key: string; patch: Record<string, unknown> }
46 | { op: 'add'; member: Partial<Member> }
47 | { op: 'del'; key: string }
48/** who wrote a change: shown when the top applies a change of rights */
49export type By = { session: string; member: string; machine: string; version: string }
50export type Change =
51 | { v: 1; kind: 'roster'; at: number; by: By; ops: Op[] }
52 | { v: 1; kind: 'settings'; at: number; by: By; patch: Partial<TeamSettings> }
53
54const NAME = /^(\d{13})-[a-z0-9]{4,16}\.json$/
55
56/** A change or queue file name: the time first, so names sort in time order. */
57export const fileName = (at: number, rand: string) => `${String(Math.max(0, Math.floor(at))).padStart(13, '0')}-${rand.replace(/[^a-z0-9]/g, '').slice(0, 16).padEnd(4, '0')}.json`
58
59/** The time in a change or queue file's name; NaN for any other file. */
60export const timeOf = (name: string) => {
61 const m = name.match(NAME)
62 return m ? Number(m[1]) : NaN
63}
64
65/** The names still to apply: well formed, not older than 7 days, not in the applied index, oldest first. */
66export const pendingNames = (names: string[], applied: ReadonlySet<string>, now: number) =>
67 names.filter(n => !applied.has(n) && now - timeOf(n) <= KEEP_MS).sort()
68
69/** The applied index after adding names: entries older than 7 days are dropped (their files are ignored by name). */
70export const appliedAfter = (prev: string[], add: string[], now: number) =>
71 [...new Set([...prev, ...add])].filter(n => now - timeOf(n) <= KEEP_MS).sort()
72
73const same = (a: unknown, b: unknown) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null)
74
75/**
76 * What one session changed in the roster since it last read it: added members, removed members, and per member only
77 * the fields that differ (null clears a field). statusFile is derived from the name, so it is never a change of its own.
78 */
79export function diffOps(base: Partial<Member>[], local: Partial<Member>[]): Op[] {
80 const b = new Map(base.map(m => [keyOf(m), project(m)]))
81 const l = new Map(local.map(m => [keyOf(m), project(m)]))
82 const ops: Op[] = []
83 for (const [k, m] of l) {
84 const old = b.get(k)
85 if (!old) {
86 ops.push({ op: 'add', member: m })
87 continue
88 }
89 const patch: Record<string, unknown> = {}
90 for (const f of STRUCT) {
91 if (f === 'statusFile') continue
92 if (!same((m as any)[f], (old as any)[f])) patch[f] = (m as any)[f] ?? null
93 }
94 if (Object.keys(patch).length > 0) ops.push({ op: 'set', key: k, patch })
95 }
96 for (const k of b.keys()) if (!l.has(k)) ops.push({ op: 'del', key: k })
97 return ops
98}
99
100const merge = <T extends object>(m: T, patch: Record<string, unknown>): T => {
101 const out = { ...m } as Record<string, unknown>
102 for (const [f, v] of Object.entries(patch)) {
103 if (v === null) delete out[f]
104 else out[f] = v
105 }
106 return out as T
107}
108
109/** The roster with ops applied in order, field by field. A set or del of a member that is gone does nothing. */
110export function applyOps<T extends Partial<Member>>(list: T[], ops: Op[]): T[] {
111 let out = list.slice()
112 for (const o of ops) {
113 if (o.op === 'del') out = out.filter(m => keyOf(m) !== o.key)
114 else if (o.op === 'set') out = out.map(m => (keyOf(m) === o.key ? merge(m, o.patch) : m))
115 else {
116 const k = keyOf(o.member)
117 out = out.some(m => keyOf(m) === k) ? out.map(m => (keyOf(m) === k ? merge(m, o.member as Record<string, unknown>) : m)) : [...out, o.member as T]
118 }
119 }
120 return out
121}
122
123/** The roster changes in a set of change files, oldest first. */
124export const rosterOps = (changes: { name: string; change: Change }[]) =>
125 changes.flatMap(c => (c.change.kind === 'roster' && Array.isArray(c.change.ops) ? c.change.ops : []))
126
127/** The settings with every settings patch applied, oldest first. */
128export const applySettings = (s: TeamSettings, changes: { name: string; change: Change }[]): TeamSettings =>
129 changes.reduce((acc, c) => (c.change.kind === 'settings' && c.change.patch && typeof c.change.patch === 'object' ? { ...acc, ...c.change.patch } : acc), s)
130
131/** The changes of rights (Allow writes, Allow subagents) a set of change files makes, in words, for the top's toast. */
132export function rightsChanges(changes: { name: string; change: Change }[]): string[] {
133 const out: string[] = []
134 for (const { change: c } of changes) {
135 if (c.kind !== 'roster') continue
136 const from = [c.by?.member || 'a session not on the roster', c.by?.machine ? `on ${c.by.machine}` : ''].filter(Boolean).join(' ')
137 for (const o of c.ops ?? []) {
138 const p: Record<string, unknown> = o.op === 'set' ? o.patch : o.op === 'add' ? (o.member as Record<string, unknown>) : {}
139 const name = o.op === 'set' ? o.key.slice(o.key.indexOf('|') + 1) : o.op === 'add' ? String(o.member.name ?? '') : ''
140 const words: string[] = []
141 if ('allowWrite' in p && (o.op === 'set' || p.allowWrite === true)) words.push(`Allow writes ${p.allowWrite === true ? 'on' : 'off'}`)
142 if ('allowAgent' in p && (o.op === 'set' || p.allowAgent === true)) words.push(`Allow subagents ${p.allowAgent === true ? 'on' : 'off'}`)
143 if (words.length > 0) out.push(`${name}: ${words.join(', ')} (from ${from})`)
144 }
145 }
146 return out
147}
148
149// ── the schema stamp ──
150
151export type Meta = { schemaVersion: number; writtenBy: string; topMachine: string }
152export const META0: Meta = { schemaVersion: 1, writtenBy: '', topMachine: '' }
153
154/** a < b for dotted versions ("0.5.9" < "0.5.12"); an unknown version on either side is never older. */
155export function olderThan(a: string, b: string): boolean {
156 if (!a || !b) return false
157 const pa = a.split('.').map(x => parseInt(x, 10) || 0)
158 const pb = b.split('.').map(x => parseInt(x, 10) || 0)
159 for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
160 const d = (pa[i] ?? 0) - (pb[i] ?? 0)
161 if (d !== 0) return d < 0
162 }
163 return false
164}
165
166/** The stamp after a write by the top on `here`: the newer version, and the top's machine kept unless none is set. */
167export const metaAfter = (m: Meta, version: string, here: string): Meta => ({
168 schemaVersion: Math.max(SCHEMA, m.schemaVersion || 0),
169 writtenBy: olderThan(m.writtenBy, version) || !m.writtenBy ? version || m.writtenBy : m.writtenBy,
170 topMachine: m.topMachine || here,
171})
172
173// ── machines ──
174
175export const sameMachine = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase()
176
177/** The member's home is another PC. A member with no machine recorded, or a PC that does not know its own name, is local. */
178export const isAway = (m: Partial<Member>, here: string) => !!here && !!m.machine && !sameMachine(m.machine, here)
179
180/** The top machine is recorded and is not this one: team-top actions here are off. */
181export const topElsewhere = (meta: Meta, here: string) => !!here && !!meta.topMachine && !sameMachine(meta.topMachine, here)
182
183/** The roster's team top for writing: its first member that reports to the user. */
184export const projectTop = <T extends Partial<Member>>(list: T[]): T | undefined => list.find(m => m.boss === 'user')
185
186/**
187 * The folder name of a project under <claude config dir>/team-orchestrator/, made from the project root the way Claude
188 * names its projects folders: every character but a letter or a digit becomes "-" (the drive letter upper case).
189 */
190export function projectKey(root: string): string {
191 const r = root.replace(/[\\/]+$/, '').replace(/^([a-z]):/, (_, d: string) => `${d.toUpperCase()}:`)
192 const key = r.replace(/[^A-Za-z0-9]/g, '-')
193 if (key.length <= 200) return key
194 let h = 5381
195 for (const ch of r) h = ((h * 33) ^ ch.charCodeAt(0)) >>> 0
196 return `${key.slice(0, 200)}-${h.toString(36)}`
197}
198
199// ── the queue ──
200
201export type QState = 'pending' | 'sending' | 'delivered' | 'failed'
202/**
203 * reason (0.5.16, #65): why a pending entry waits for its member to answer rather than for room. hung: its claude
204 * process runs but it has written no status for a while; unsure: whether it runs could not be proved. Either is tried
205 * when the member's heartbeat is fresh again, or once it is proved dead and closed (then it is reopened first). told:
206 * when the team top was told the member looks hung (once per member per hour, across sessions).
207 */
208export type QReason = 'hung' | 'unsure'
209export type QEntry = { to: string; from: string; message: string; created: number; state: QState; tries: number; updated?: number; error?: string; reason?: QReason; told?: number }
210
211/** What the top's round does with one entry: prune it, keep it as it is, or try to deliver it. */
212export function queueAction(q: QEntry, now: number): 'prune' | 'keep' | 'try' {
213 if (now - q.created > KEEP_MS) return 'prune'
214 const since = now - (q.updated ?? q.created)
215 if (q.state === 'delivered' || q.state === 'failed') return since > DAY ? 'prune' : 'keep'
216 if (q.state === 'sending' && since < SENDING_MS) return 'keep'
217 return 'try'
218}
219
220/** The entry after one try: delivered; back to pending when there was no room (not a failed try); failed after the third failure. */
221export function afterTry(q: QEntry, ok: boolean | undefined, now: number, error = ''): QEntry {
222 if (ok === true) return { ...q, state: 'delivered', updated: now, error: undefined }
223 if (ok === undefined) return { ...q, state: 'pending', updated: now }
224 const tries = q.tries + 1
225 return { ...q, tries, state: tries >= MAX_TRIES ? 'failed' : 'pending', updated: now, error }
226}
227
228/** The old single queue.json (before 0.5.12) as queue entries. */
229export const fromOldQueue = (rows: unknown, now: number): QEntry[] =>
230 (Array.isArray(rows) ? rows : [])
231 .filter((r: any) => r && typeof r.to === 'string' && typeof r.message === 'string')
232 .map((r: any) => ({ to: r.to, from: String(r.from ?? 'someone'), message: r.message, created: Number(r.at) || now, state: 'pending' as const, tries: 0 }))
233hooks/layout.ts 151 lines1// The roster table's columns and the org chart's labels, from the members and the width alone: no $, no state,
2// so a test can call them. Every cell is cut with "…" to its width and ends in one space, so nothing wraps and
3// two values never touch.
4
5export type Tier = 'wide' | 'medium' | 'narrow'
6export type Col = { id: string; w: number; head: string }
7export type ColumnPlan = { tier: Tier; nameW: number; cols: Col[]; total: number }
8
9// "[x] " before the name; a row has no buttons (Open and Remove are in Team actions)
10export const CHECK = 6
11
12// Cells per tier, each width counting its trailing space. Measured sums: wide 58, medium 38, narrow 18; with the
13// checkbox a row needs 62 / 42 / 22 cells plus the name.
14const TIERS: { tier: Tier; cols: [string, number][] }[] = [
15 { tier: 'wide', cols: [['STATUS', 11], ['CONTEXT', 16], ['MODEL', 14], ['EFFORT', 8], ['BRIEF', 9]] },
16 { tier: 'medium', cols: [['STATUS', 11], ['CONTEXT', 12], ['MODEL', 8], ['EFFORT', 7]] },
17 { tier: 'narrow', cols: [['STATUS', 2], ['CONTEXT', 5], ['MODEL', 7], ['EFFORT', 4]] },
18]
19const SHORT_HEAD: Record<string, string> = { STATUS: '', CONTEXT: 'CTX', MODEL: 'MODEL', EFFORT: 'EFF', BRIEF: 'BR' }
20// a tier is taken while the name keeps this much (or all of itself, when shorter)
21const NAME_ROOM = 16
22const NAME_MIN = 8
23
24/**
25 * Cards per row in the side-by-side layout. Each card needs at least the medium tier with a short name (or, to keep
26 * two side by side, the narrow tier); a card that gets more width simply draws a wider tier. So a 1920x1080 screen
27 * (about 160-210 columns) shows a 2 x 2 grid for four teams instead of one card per row.
28 */
29export function sideBySide(cols: number, cards: number, frame: number): number {
30 if (cards <= 1) return 1
31 const mediumMin = CHECK + 38 + NAME_ROOM + frame
32 const narrowMin = CHECK + 18 + NAME_MIN + frame
33 const fit = Math.floor(cols / mediumMin)
34 const n = fit >= 2 ? fit : Math.floor(cols / narrowMin) >= 2 ? 2 : 1
35 return Math.max(1, Math.min(cards, n))
36}
37
38/** `s` cut to `w` cells, the last one "…" when cut */
39export const fit = (s: string, w: number) => (w <= 0 ? '' : s.length <= w ? s : `${s.slice(0, w - 1)}…`)
40/** `s` as a cell of `w`: cut to w - 1, then padded, so one space always follows */
41export const cell = (s: string, w: number) => fit(s, w - 1).padEnd(w)
42
43/**
44 * The widest tier whose cells leave the names room in `width` (the cells inside a card). `hide` is the settings'
45 * column toggles, applied on top of the tier.
46 */
47export function columnPlan(rows: { prefix: string; name: string }[], width: number, hide: string[]): ColumnPlan {
48 const want = Math.max(4, ...rows.map(r => r.prefix.length + r.name.length)) + 1
49 for (const t of TIERS) {
50 const cols = t.cols
51 .filter(([id]) => !hide.includes(id))
52 .map(([id, w]) => ({ id, w, head: id.length <= w - 1 ? id : (SHORT_HEAD[id] ?? '') }))
53 const fixed = CHECK + cols.reduce((a, c) => a + c.w, 0)
54 const room = width - fixed
55 if (room < Math.min(want, NAME_ROOM) && t.tier !== 'narrow') continue
56 const nameW = Math.max(NAME_MIN, Math.min(want, room))
57 return { tier: t.tier, nameW, cols, total: fixed + nameW }
58 }
59 throw new Error('unreachable: the narrow tier is always taken')
60}
61
62/** The header line, cut to the same widths as the cells below it. */
63export const headerLine = (p: ColumnPlan) => ' '.repeat(CHECK) + cell('NAME', p.nameW) + p.cols.map(c => cell(c.head, c.w)).join('')
64
65/** "Opus 5.5" / "opus-5-5" -> "Opus": the family, for the tiers that are short of room */
66export const family = (model: string) => {
67 const w = model.split(/[\s-]+/).find(x => x !== '') ?? ''
68 return w === '' ? '' : w[0]!.toUpperCase() + w.slice(1)
69}
70export const EFFORT_SHORT: Record<string, string> = { low: 'L', medium: 'M', high: 'H', xhigh: 'XH', max: 'MAX' }
71
72// ── org chart labels ──
73// A member's label is, first of all, the short name the person gave it (`short`), used as typed. Only a member with no
74// short name gets one from the rule below, and the rule keeps away from every short name already taken.
75// The rule makes the shortest label that tells each member apart, for any names, worked out each draw:
76// 1. drop the words the name shares with its team ("Hualong PC Worker A" in team Hualong -> "PC Worker A"),
77// and the words every member of the team starts with;
78// 2. try, in order: a leading acronym and the last word ("PC A", "PC Boss"); the initial and a number or letter
79// tail ("Worker 5" -> "W5", "Lead 1 2" -> "L1.2"); the last word ("CEO", "Workers"); the first word
80// ("Research Worker" -> "Research"); all the words left;
81// 3. members still alike get the team in front ("Dev Head", "UI Head"), then the whole name.
82// Every automatic member starts at its first try; those that clash (with another automatic label or with a short name)
83// move one try on, until no two labels are the same. Two characters at least. The rule knows nothing about what a
84// name means: "HK University of Hong Kong president" only becomes "HK president" when a person says so.
85// Two members that were given the same short name both keep it, and both are flagged (`dup`): the chart adds "!"
86// and the roster row says so. Nothing is renamed behind the person's back.
87const words = (s: string) => s.split(/[\s_-]+/).filter(w => w !== '')
88const isTail = (w: string) => /^\d+$/.test(w) || w.length <= 2
89
90export type LabelInfo = {
91 /** what the chart shows (before any "…" cut to fit) */
92 label: string
93 /** worked out by the rule, not given by the person: drawn dim */
94 auto: boolean
95 /** the person gave this short name to more than one member */
96 dup: boolean
97}
98type Labelled = { team: string; name: string; short?: string }
99
100/** `s` cut to `max` characters with "…" as the last one, when it is longer */
101export const shorten = (s: string, max: number) => (s.length <= max ? s : max <= 1 ? '…' : `${s.slice(0, max - 1)}…`)
102
103export function chartLabelInfo(list: Labelled[]): Map<string, LabelInfo> {
104 const given = list.map(m => (m.short ?? '').trim())
105 const rests = new Map<string, string[]>()
106 for (const team of new Set(list.map(m => m.team))) {
107 const mine = list.filter(m => m.team === team)
108 const lead = words(team)
109 const own = mine.map(m => {
110 const w = words(m.name)
111 return w.length > lead.length && lead.every((x, i) => w[i]?.toLowerCase() === x.toLowerCase()) ? w.slice(lead.length) : w
112 })
113 let n = 0
114 if (mine.length > 1) while (own.every(w => w.length > n + 1 && w[n] === own[0]![n])) n++
115 mine.forEach((m, i) => rests.set(`${m.team}|${m.name}`, own[i]!.slice(n)))
116 }
117 const ladder = (m: Labelled): string[] => {
118 const w = rests.get(`${m.team}|${m.name}`) ?? words(m.name)
119 const first = w[0] ?? m.name
120 const last = w[w.length - 1] ?? m.name
121 const tries: string[] = []
122 if (w.length > 1 && /^[A-Z0-9]{2,}$/.test(first)) tries.push(`${first} ${last}`)
123 if (w.length > 1 && w.slice(1).every(isTail)) tries.push(`${first[0]}${w.slice(1).join('.')}`)
124 tries.push(last, first, w.join(' '))
125 const inTeam = tries.filter(x => x.length >= 2)
126 const tag = words(m.team)[0] ?? m.team
127 return [...new Set([...inTeam, ...inTeam.map(x => `${tag} ${x}`), m.name, `${m.team}/${m.name}`])].filter(x => x.length >= 2)
128 }
129 const steps = list.map(ladder)
130 const at = list.map(() => 0)
131 const label = (i: number) => (given[i] !== '' ? (given[i] as string) : steps[i]![Math.min(at[i]!, steps[i]!.length - 1)]!)
132 for (let round = 0; round < 20; round++) {
133 const count = new Map<string, number>()
134 list.forEach((_, i) => count.set(label(i).toLowerCase(), (count.get(label(i).toLowerCase()) ?? 0) + 1))
135 // only the automatic labels move; a short name the person gave stays where it is
136 const clash = list.map((_, i) => given[i] === '' && (count.get(label(i).toLowerCase()) ?? 0) > 1)
137 if (!clash.some(Boolean)) break
138 clash.forEach((c, i) => c && (at[i] = at[i]! + 1))
139 }
140 const same = new Map<string, number>()
141 given.forEach(g => g !== '' && same.set(g.toLowerCase(), (same.get(g.toLowerCase()) ?? 0) + 1))
142 return new Map(
143 list.map((m, i) => [`${m.team}|${m.name}`, { label: label(i), auto: given[i] === '', dup: given[i] !== '' && (same.get(given[i]!.toLowerCase()) ?? 0) > 1 }]),
144 )
145}
146
147/** Just the labels, for a caller that does not need to know which are automatic. */
148export function chartLabels(list: Labelled[]): Map<string, string> {
149 return new Map([...chartLabelInfo(list)].map(([k, v]) => [k, v.label]))
150}
151types/index.d.ts 125 lines1export type Form = {
2 team: string
3 fn: string
4 levels: string
5 fan: string
6 /** '1' = a CEO above several teams, '0' = one team */
7 ceo: string
8 /** with a CEO: the team names, comma separated, one head each */
9 groups: string
10 // per-level defaults: model and effort for level 1 (head), 2, 3; 'default' = leave to claude
11 m1: string
12 e1: string
13 m2: string
14 e2: string
15 m3: string
16 e3: string
17}
18export type Member = {
19 /** the team this session belongs to */
20 team: string
21 name: string
22 /** the session's real name, what SendMessage addresses it by (defaults to name) */
23 address?: string
24 role: string
25 level: number
26 boss: string
27 handle: string
28 sessionId: string
29 /** starting | working | idle | asking | offline | failed | <raw orca state> */
30 state: string
31 /** context used, percent, -1 unknown */
32 ctx: number
33 model: string
34 effort: string
35 sel: boolean
36 note: string
37 /** the short name the person gave for the org chart; empty or missing: the chart works one out */
38 short?: string
39 /** this member may use the Agent tool (subagents); off unless set here or by #allow-subagent for one turn */
40 allowAgent?: boolean
41 /** a member with reports may use Write, Edit and NotebookEdit; off unless set here or by #allow-write for one turn */
42 allowWrite?: boolean
43 /** the one-time team briefing was sent */
44 briefed: boolean
45 /** the session answered "Noted" on screen */
46 noted: boolean
47 /** the Orca workspace (worktree id) the member was launched in; a reopen goes back there, never to the "active" one */
48 worktree?: string
49 /** the member's home machine (its computer name), set at launch, adopt, reopen and identity re-attach; empty: this one */
50 machine?: string
51 /** the status file this member's session writes, relative to <claude config dir>/team-orchestrator/<project>/ on its machine (status/<name>.json) */
52 statusFile?: string
53 /** on the roster since Create but never started: it starts, fresh and briefed, on its first message (team_message) */
54 pending?: boolean
55 /**
56 * how a message reaches it (0.5.13, #67): local (this machine, by session id), remote (another device, the messenger
57 * route of #72) or other-cli (not a Claude session: never messaged). Recorded at launch, adopt and reopen.
58 */
59 location?: 'local' | 'remote' | 'other-cli'
60 /** the CLI its tab runs, detected at adopt (claude, codex, hermes, gemini, opencode, qwen, other); empty: claude (#69) */
61 cli?: string
62}
63export type Bulk = {
64 prefix: string
65 base: string
66 /** none | 1 | 01 */
67 numbering: string
68 model: string
69 effort: string
70 msg: string
71}
72/** the Actions menu of the roster: what it is doing for the ticked rows */
73export type Act = {
74 /** the team whose Team actions list is open, '' none */
75 menu: string
76 /** none | remove | rmteam | boss | bulk | add | new | short */
77 kind: string
78 /** the team card the open action (and its message) belongs to */
79 to: string
80 /** short: the row being named (team|name), and the text typed so far */
81 key: string
82 draft: string
83 /** add and new: the boss; boss: the new boss */
84 boss: string
85 /** add: the picked tab's handle; add and new: its role */
86 handle: string
87 role: string
88 /** new: the New member's name, model and effort ('default' leaves them to claude) */
89 name: string
90 model: string
91 effort: string
92 /** add: live tabs not on the roster, read when the action was picked */
93 tabs: { handle: string; title: string }[]
94 msg: string
95}
96export type View = 'closed' | 'roster' | 'new' | 'settings'
97/** a Create in progress: the members it starts now, the batch being started (1-based) and how many batches; empty when none */
98export type Spawn = { names: string[]; batch: number; of: number }
99export type Settings = {
100 /** stacked: team cards one under another | columns: side by side where the width allows | dock: the panel in a side pane */
101 layout: 'stacked' | 'columns' | 'dock'
102 /** show the live org chart */
103 chart: boolean
104 /** table columns hidden (STATUS, CONTEXT, MODEL, EFFORT, BRIEF); NAME always shows */
105 hide: string[]
106}
107
108declare module 'claude-code' {
109 interface PluginState {
110 'team-orchestrator': {
111 form: Form
112 members: Member[]
113 note: string
114 bulk: Bulk
115 view: View
116 teamName: string
117 frame: number
118 menu: string
119 settings: Settings
120 act: Act
121 spawn: Spawn
122 }
123 }
124}
125