SLOPSHOPPER

team-orchestrator

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…

newpanebandguardcommandtoast
★ 2v0.5.18no licenseupdated 2026-10-09herman925/925-cc-plugins/team-orchestrator
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · team-orchestrator
│ ┃ team-dock ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ◆ TEAM ORCHESTRATOR [ Roster ][ New team ⏺ Read(src/auth.ts) │ ┃ ╰─────────────────────────────────────────── ⎿ Read 6 lines │ ┃ ╔═══════════════════════════════════════════ ⏺ Update(src/auth.ts) │ ┃ ║ ◆ NO TEAM YET ⎿ Added 2 lines, removed 1 line │ ┃ ║ A team is a set of Claude Code ⏺ Bash(bun test) │ ┃ ║ sessions, one Orca tab each, that ⎿ 3 pass, 1 fail │ ┃ ║ report up a chain. │ ┃ ║ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ║ ● Head you talk to │ ┃ ║ it; it plans, delegates and reviews ✻ Worked for 42s · done 4:20 PM │ ┃ ║ ┌────┼────┐ │ ┃ ║ ○ ○ ○ Workers run the › /team │ ┃ ║ tasks and report to their boss ⎿ team-orchestrator: Team Orchestrator opened above the prompt. Re │ ┃ ║ │ ┃ ║ ┤ Quick start ├ │ ┃ ║ [ Squad ] ●┬○○○ head + 3 w │ ┃ ║ [ All-Purpose Team ] ■┬●● CEO + 2 he │ ┃ ║ [ Tech Team ] ■┬●●●● CEO + 4 he │ ┃ ║ [ Interview me instead ] Claude asks you │ ┃ ║ builds the team │ ┃ ╚═══════════════════════════════════════════ │ ┃ [ Refresh ][ Clear selection ][ Settings ] │ ◆ Team Orchestrator [ ▾ ] │ ●┬●○○ no team yet · press t to spawn a head + workers as Orca tabs ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ◆ TEAM ORCHESTRATOR [ Roster ][ New team ][ Settings ] [ x Close ] │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╔══════════════════════════════════════════════════════════════════════════════════════════════════════════════════╗ ║ ◆ NO TEAM YET ║ ║ A team is a set of Claude Code sessions, one Orca tab each, that report up a chain. ║ ║ ║ ║ ● Head you talk to it; it plans, delegates and reviews ║ ║ ┌────┼────┐ ║ ║ ○ ○ ○ Workers run the tasks and report to their boss ║ ║ ║ ║ ┤ Quick start ├ ║ ║ [ Squad ] ●┬○○○ head + 3 workers ║ ║ [ All-Purpose Team ] ■┬●● CEO + 2 heads, 4 workers each ║ ║ [ Tech Team ] ■┬●●●● CEO + 4 heads, 4 workers each ║ ║ [ Interview me instead ] Claude asks you questions, then builds the team ║ ╚══════════════════════════════════════════════════════════════════════════════════════════════════════════════════╝ [ Refresh ][ Clear selection ][ Settings ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◆ Team Orchestrator [ ▾ ] │ ●┬●○○ no team yet · press t to spawn a head + workers as Orca tabs ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ◆ TEAM ORCHESTRATOR [ Roster ][ New team ][ Settings ] [ x Close ] │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ╔══════════════════════════════════════════════════════════════════════════════════════════════════╗ ║ ◆ NO TEAM YET ║ ║ A team is a set of Claude Code sessions, one Orca tab each, that report up a chain. ║ ║ ║ ║ ● Head you talk to it; it plans, delegates and reviews ║ ║ ┌────┼────┐ ║ ║ ○ ○ ○ Workers run the tasks and report to their boss ║ ║ ║ ║ ┤ Quick start ├ ║ ║ [ Squad ] ●┬○○○ head + 3 workers ║ ║ [ All-Purpose Team ] ■┬●● CEO + 2 heads, 4 workers each ║ ║ [ Tech Team ] ■┬●●●● CEO + 4 heads, 4 workers each ║ ║ [ Interview me instead ] Claude asks you questions, then builds the team ║ ╚══════════════════════════════════════════════════════════════════════════════════════════════════╝ [ Refresh ][ Clear selection ][ Settings ] ⟨Claude Code's own drawing⟩
Pane · team-dock
╭─────────────────────────────────────────────────────────── │ ◆ TEAM ORCHESTRATOR [ Roster ][ New team ][ Settings ][ x ╰─────────────────────────────────────────────────────────── ╔═══════════════════════════════════════════════════════════ ║ ◆ NO TEAM YET ║ A team is a set of Claude Code sessions, one Orca tab ║ each, that report up a chain. ║ ║ ● Head you talk to it; it plans, ║ delegates and reviews ║ ┌────┼────┐ ║ ○ ○ ○ Workers run the tasks and report ║ to their boss ║ ║ ┤ Quick start ├ ║ [ Squad ] ●┬○○○ head + 3 workers ║ [ All-Purpose Team ] ■┬●● CEO + 2 heads, 4 workers e ║ [ Tech Team ] ■┬●●●● CEO + 4 heads, 4 workers e ║ [ Interview me instead ] Claude asks you questions, then ╚═══════════════════════════════════════════════════════════ [ Refresh ][ Clear selection ][ Settings ]
README

team-orchestrator

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.

Contents

Features

FeatureWhat it does
Team builderQuick starts (Squad, All-Purpose Team, Tech Team), a team form, or an interview that designs the team with you
Live rosterOne card per team: state, context use, model, effort and briefing for every member
Org chartA live chart whose dots follow each member's state
LayoutsStacked, side by side, or docked beside the transcript
On-demand startLeads and workers start the first time their boss messages them
Role filesOne file per member: a generated role, plus your own notes that the mod never changes
Team messagingteam_message starts or reopens a member, then delivers. @team sends your message to a team's head.
Adding membersNew members on a running team, within a lead's purview and with your approval (0.5.17)
Auto-close and reopenIdle workers close; a message reopens them with their conversation
Session cap and queueA limit on open sessions; messages wait until there is room
Permission guardNo subagents for members, and no file writes for members with reports, unless you allow it
Locked team filesOnly the team top changes the roster and the team settings
Identity trackingMembers stay recognised after /clear, /rename or a fresh claude; unclear sessions are held for you
Crash and sleep handlingWaking up closes nothing; only members proved to have crashed are reopened
Two computersA team can span computers that share one project folder
Other CLIsTabs that run another CLI can sit on the roster, unmanaged
Windows, macOS and LinuxEvery platform Orca runs on

Install

Requirements

WhatDetails
Claude CodeA build that loads mods (plugins with hooks). Every member runs claude in its own tab.
OrcaOrca must be running, and its command-line tool must work. The mod opens, closes and switches tabs through it.
Operating systemWindows, 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.

ValueWhat 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 pathThe 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 computerIf the platform default works, the mod switches to it and shows a toast. This covers settings synced from another platform.

Install the mod

  1. Add the marketplace: claude plugin marketplace add herman925/925-cc-plugins
  2. Install the mod: claude plugin install team-orchestrator@herman-mods --scope user
  3. In a session that is already open, run /reload-plugins.

Update

  1. Update the marketplace: claude plugin marketplace update herman-mods
  2. Update the mod: claude plugin update team-orchestrator@herman-mods
  3. Run /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.

Quick start

  1. Open a Claude Code session in an Orca tab, in your project.
  2. Press t, or type /team. The panel opens above the prompt.
  3. Pick a quick start on the welcome screen, or press Interview me instead.
  4. Check the form, then press Launch.
  5. Give the team work: type @<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.

Security

The permission guard

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.

ToolWho is refusedUnless
Agent (subagents)Every roster memberAllow subagents is on, or #allow-subagent this turn. Claude Code's own helpers statusline-setup and claude-code-guide always pass.
Write, Edit, NotebookEditMembers 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 filesEvery member except the team topNo switch or keyword opens them.
Bash, PowerShell commands that name the team filesEvery member except the team topPlain 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.

How you allow something

WayHow longHow
Standing switchUntil you switch it offSettings → Permissions → Allow subagents / Allow writes, per member. Stored in the roster, so it counts at once in every session.
One-turn keywordThe current turnType #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.

Helpers inherit their spawner's grants

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.

Rights changed outside the mod

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.

One writer for the team files

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

  • Every other session writes a small change file in changes/ with only the fields it changed. Its own panel shows the change at once.
  • Every session reads the roster as the file plus the change files not applied yet, oldest first. So all sessions see the same team.
  • The team top folds the change files in on its next refresh, within 30 seconds. Applied change files are listed in changes/applied.json. Change files older than 7 days are ignored.
  • While no team top is running on this computer, your own session (not on the roster) writes in its place.
  • 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.
  • A change of Allow writes or Allow subagents that arrives in a change file is shown to the team top, with who made it.
  • Requests for new members in 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.

What the mod can and cannot touch

The mod canThe mod does not
Open, close and switch Orca tabs of this project's members on this computerClose or reopen members of other computers, or members that run another CLI
Start claude sessions for members, with their role pointerKill 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/excludeDelete 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 idPoll processes on a timer
Approve a plain delete of a member's own scratch filesOverride a deny from your rules or your organisation
Send messages to members by session idMessage sessions of other projects or Remote Control copies

Scratch clean-up without asking

With Scratch: Auto-approve clean-up on, the mod approves some deletes that Claude Code would otherwise ask about.

MemberMay delete without asking
WorkerInside its own session's folder in the Claude temp folder
Head or leadThe above, plus anything in the system temp folder and in the project's scratch folder (Scratch dir)

Limits:

  • Only one plain delete command. No pipes, chains, redirects or variables.
  • Only plain literal paths. A wildcard (*, ?, [, ]) or a trailing slash asks.
  • Every folder from the allowed root down to the target is checked on disk. A link asks.
  • A recursive delete also walks the target, up to 2000 entries. It asks if it finds a link, or if the folder holds more entries than that.
  • The mod only lifts an "ask" to "allow". It never overrides a deny.

Details

Settings

Per session. These change only the look of your own panel. They survive /reload-plugins, not a new session.

SettingWhat it doesDefault
LayoutStacked, Side by side or Dock right. See Layouts.Stacked
Org chartShows or hides the live org chart above the cardsShow
ColumnsShows 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.

SettingWhat it doesDefault
Auto-closeCloses workers that said "clean" and stayed idle (On, Off)On
Idle minutesHow long a worker stays idle before it is closed (5, 10, 20, 30, 60)10
Reopen asResume keeps the conversation; Fresh starts a new session that reads its role file againResume
Max openMost of this computer's members open at once (No cap, 6, 8, 10, 12)8
LaunchOn demand starts only the top and the team heads; All at Create starts everyoneOn demand
Batch sizeSessions started at once at Launch (1, 2, 3, 4, 6); the next batch follows 5 seconds after the last is ready3
ScratchAuto-approve clean-up, or Ask each time. See Scratch clean-up.Auto-approve
Scratch dirThe project's scratch folder that heads and leads may clean without asking.claude/scratch
Never closeA tick box per worker; a ticked worker is never auto-closedNone ticked

Per member, team-wide (Settings → Permissions). Stored in the roster.

SettingWhat it doesDefault
Allow subagentsLets this member use the Agent toolOff
Allow writesLets this member, if it has reports, use Write, Edit and NotebookEditOff

Plugin option.

SettingWhat it doesDefault
Orca command (orcaCommand)Orca's command-line tool, or its full path. See Requirements.Empty: detected and saved at the next start

Concepts

Teams and levels

A project can hold several teams. Each member has a name, a role, a level and a boss.

TermWho it is
Team topThe 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.
HeadThe member at the top of one team. Under a CEO, each team has its own head, which reports to the CEO.
LeadA member with its own reports, below a head. Leads appear in three-level teams.
WorkerA 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).

Role files

Each member has a role file: .claude/team-orchestrator/roles/<name>.md. It has two parts.

PartContentsWho writes it
Above the markerThe member's job for its level, its boss and reports, how to message, housekeeping, the whole team, and the team filesThe mod. It rewrites this part whenever the team changes.
Personality and notes, below the markerA voice, a working style or extra rules for this memberYou. 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.

Staged, on-demand start

Launch writes the whole roster first. Then it starts sessions in batches.

  • On demand (the default): only the team top and, under a CEO, each team head start now. Leads and workers show as not yet. Each one starts, fresh and briefed, the first time its boss messages it with team_message.
  • All at Create: everyone starts now.

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
ToolUse it forWhy
team_messageA boss messaging its own reportsIt 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.
SendMessageA worker reporting to its boss, and heads and leads talking to each otherIt 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.

Messaging a whole team

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.

Where live status lives

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.

Computers and "on PC-X"

Every member records its home computer (its computer name) at launch, adopt, reopen and identity re-attach.

  • A member whose home is another computer shows dimmed on the roster, with on PC-X.
  • This computer never reopens, closes, tab-checks or messages that member.
  • 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.
  • When 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.
Identity and member_claim

A 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 matchResult
Tab, id and nameThe 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 tabThe 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 onlyOn hold
NothingNot 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:

ChoiceEffect, applied by the team top with member_claim
This is XThe roster takes the session's id, tab and name as member X.
New member under X's bossThe session joins as a worker under that boss, with its own role file.
RejectThe session stays on hold, off the team.
Adding members to a running team (0.5.17)

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_addWhat happens
Your own session (not on the roster)The member is added at once.
The team topThe 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 topA 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 workerRefused. A worker asks its boss.
A session on holdRefused, like every team tool.
  • Purview. A head or lead may add or request members only for its own team and every team whose chain of bosses leads up to it. A lead over two teams may request members for those two teams and no others. A request outside the purview is refused.
  • Approval. Only the team top may apply a request. An approval counts only after an AskUserQuestion answered in the same turn. A decline needs no such answer. Either way, the member who asked is told the outcome.
  • When the top cannot be reached. The request stays saved, and you get a toast with its id. Tell the top to apply it once you approve.
Launching under a team name that is taken (0.5.17)

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.

Choiceteam_launch withWhat happens
AddifExists: "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).
MergeifExists: "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

Source 11 files
hooks/register.tsx 4120 lines
1import { 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 lines
1// 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}
131
hooks/guard.ts 227 lines
1// 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}
227
hooks/housekeeping.ts 88 lines
1// 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}
88
hooks/identity.ts 224 lines
1// 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}
224
hooks/status.ts 250 lines
1// 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)}/`)
250
hooks/platform.ts 255 lines
1// 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}
255
hooks/roles.ts 105 lines
1// 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}
105
hooks/changes.ts 233 lines
1// 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 }))
233
hooks/layout.ts 151 lines
1// 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}
151
types/index.d.ts 125 lines
1export 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