ac-teams 0.0.6 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,139 @@
1
+ ## Session roles: promotion, joining, and several main sessions
2
+
3
+ Status: draft for review. Implements nothing yet. Resolves the design questions in [issue #19](https://github.com/ac8318740/ac-teams/issues/19).
4
+
5
+ A team can have several **main sessions**. A main session is a Pi session the user created, joined to a team, or promoted. Every other team process is a **member**. The user can promote a member to a main session from the TUI, or join an existing session to a team with `/teams join`. ac-teams marks every member process with `ACSTACK_AGENT_ROLE=member`, so ACStack's large-read guard applies only to main sessions.
6
+
7
+ ### Review questions
8
+
9
+ 1. Does promotion by relaunch feel seamless enough, given that a promoted session moves to its own Herdr tab?
10
+ 2. Does it look right that every main session can control every member and other main sessions? That matches what an unsandboxed session can already do through its shell.
11
+ 3. Is the takeover after the owner exits (automatic, with a notice) the right default?
12
+ 4. Are the changes and tests at the end complete enough to start implementation?
13
+
14
+ ### Trade-offs to judge
15
+
16
+ - **Relaunch, not live handover.** Promotion stops the member process and reopens its transcript in a new process. The member loses its in-flight turn, but its role, sandbox, token, and environment change cleanly.
17
+ - **No coordinator daemon.** The coordinator stays inside whichever main session holds `owner.lock`. When the owner exits, members stop and the next owner resumes them automatically. The cost is a pause and at most one redone turn per busy member.
18
+ - **Wide main-session authority.** Any main session can stop work started by another. ac-teams does not try to guard against this, because an unsandboxed session can already stop processes through the shell or Herdr.
19
+
20
+ ## Definitions
21
+
22
+ These five properties are independent. A change to one never changes another.
23
+
24
+ | Property | Defined by | Changes when |
25
+ |---|---|---|
26
+ | Identity | Pi session id in the transcript header | Never |
27
+ | Discovery | Where the transcript file lives | Promotion moves it to the top-level session folder |
28
+ | Membership | A member record in the team store | Join adds one; leave clears its role |
29
+ | Coordinator ownership | Holder of `owner.lock` | The owner exits and another main session takes it |
30
+ | Authority | The record's `role`: `main` or `member` | Only promotion, join, or leave, all user actions |
31
+
32
+ ### What the coordinator does
33
+
34
+ The coordinator is ac-teams' in-process server. Today it runs inside Main. In this design, it runs inside whichever main session holds `owner.lock`. In practice it:
35
+
36
+ - **Runs member processes.** Starts each member's Pi, either headless or in tmux, optionally inside a Docker sandbox. It also stops and resumes members, and stops them all when its host session exits
37
+ - **Answers team tools.** Every `team_agent`, `team_task`, and `team_message` call from any session arrives over `coordinator.sock` and is applied here, so concurrent edits are serialized
38
+ - **Keeps team state.** Writes members, tasks, and messages to the team store
39
+ - **Delivers notifications.** Tells each session when new team messages arrive
40
+ - **Enforces rules.** Checks authority, depth and member limits, and sandbox grants, and brokers provider credentials for sandboxed members
41
+
42
+ This design keeps all of that. It adds role checks, connections from main sessions it did not launch, promotion, joining, leaving, and ownership takeover.
43
+
44
+ The team id stays the session id of the session that created the team, even after that session leaves or hands over ownership.
45
+
46
+ ### Main-agent signal
47
+
48
+ - The coordinator sets `ACSTACK_AGENT_ROLE=member` on every member process, next to `AC_TEAMS_MEMBER`. That includes the terminal-launch script, which today only passes `AC_TEAMS_*` and `HERDR_ENV`
49
+ - A hook treats the session as main when the variable is absent or `main`, and as not main for any other value
50
+ - Anything a member starts inherits the variable and counts as not main
51
+ - Promotion and every main-session launch must remove `ACSTACK_AGENT_ROLE`, every `AC_TEAMS_*` variable, and `HERDR_ENV=0`. Tests must cover this
52
+ - ACStack reads the variable on every tool call. It detects Claude Code and Codex subagents from `agent_id` in their hook payloads instead. That detection belongs to ACStack, not ac-teams
53
+ - Role grants no permission by itself
54
+
55
+ ## User flows
56
+
57
+ ### Promote a member
58
+
59
+ 1. The user opens a teammate from the agent selector or `/teams` and chooses **Promote to main**
60
+ 2. If the member is sandboxed, a confirmation states that the session will run with the user's full access. Declining cancels
61
+ 3. The coordinator stops the member, waiting for its process to exit as `stop` does today
62
+ 4. The coordinator moves the transcript from `ac-teams/<team>/<member>/` to the project's top-level session folder. It removes the member's config at `~/.pi/ac-teams/agents/<id>` and sets the member's role to `main` without a parent. The member's children stay its descendants
63
+ 5. The coordinator writes the session's team entry: a Pi custom entry holding the team id and member id, with no secrets
64
+ 6. The coordinator relaunches `pi --session <moved file>` with a clean environment:
65
+ - With Herdr, in a new tab in the workspace for the session's worktree, creating the workspace if needed. The tab gets focus and the session's name
66
+ - Without Herdr, in a tmux session on a per-main-session socket that the owner's `kill-server` cannot reach. The user's screen stays attached, and ac-teams prints the reattach command
67
+ 7. The new process reads its team entry, connects to the coordinator with a main-session token from the team store, and appears in `/resume`
68
+
69
+ ### Join an existing session to a team
70
+
71
+ 1. The user runs `/teams join` in an ordinary Pi session
72
+ 2. ac-teams lists this project's teams. The user picks one
73
+ 3. ac-teams refuses if the session already belongs to a team with members, or if its session id is already in the chosen team
74
+ 4. The coordinator adds a member record with role `main` and writes the team entry
75
+ 5. The session receives a short summary of open tasks, owners, and recent messages, with its message cursor at the latest message. It claims or creates work with the normal team tools
76
+
77
+ ### Several main sessions
78
+
79
+ - Each main session holds one connection to the coordinator. The owner hosts it in-process; the others connect over `coordinator.sock`
80
+ - Every main session sits at depth 0. Any main session can launch, stop, resume, and clean up any member, and stop or resume another main session
81
+ - Stopping a main session asks it to shut down through its connection. Resuming one relaunches its transcript, as in step 6 of promotion
82
+ - Members keep today's rule: they control only their descendants and never a main session
83
+ - Tasks and messages stay shared. Ownership and revision checks still serialize concurrent edits, because one coordinator applies them all
84
+
85
+ ### Leave a team
86
+
87
+ `/teams leave` returns the session's in-progress tasks to todo and clears their ownership. The member record keeps its history but loses its role, so its descendants stay under the other main sessions. The team entry records the departure, so a later `/resume` does not rejoin. Leaving is refused while the session is the only main session and members are still live.
88
+
89
+ ## Recovery
90
+
91
+ - **The owner exits while another main session runs.** Before stopping members, the owner records each one's state (busy, idle, or needs input) in the team store. Today, `Coordinator.open` discards that state. A connected main session then takes `owner.lock`, opens the coordinator, and runs the resume pass below. Every main session gets a notice naming the new owner
92
+ - **No main session is running when the owner exits.** Members stay stopped with their recorded state. The next main session that opens the team runs the same resume pass
93
+ - **A promoted session is resumed with no coordinator running.** The extension reads its team entry at startup and offers to take ownership. Accepting opens the coordinator and runs the resume pass. Declining runs the session without team tools
94
+
95
+ ### Resume pass
96
+
97
+ | Recorded state | Action |
98
+ |---|---|
99
+ | Busy | Resume and send: "Your process was restarted during a coordinator handover. Check your last steps and continue your current task." |
100
+ | Idle | Resume and leave idle |
101
+ | Needs input | Resume and show **Needs input** again |
102
+
103
+ Pi saves each completed message, so a busy member loses only the reply it was generating and redoes that turn. A command killed mid-run may have half finished. The message tells the member to check before it continues, which reduces that risk but does not remove it, the same as stopping a member today.
104
+ - **A main session crashes.** Its record stays. The coordinator marks it interrupted, and any main session can resume it
105
+ - **Promotion fails partway.** Each step is recorded in the team store before the next starts. On failure, the coordinator rolls back to a stopped member with its original transcript path, and the member can be retried or resumed
106
+
107
+ ## Security boundaries
108
+
109
+ - Only the user can promote, join, or leave. These actions have no agent-tool form
110
+ - Promoting a sandboxed member is an explicit escalation, approved in the confirmation step. Without approval, nothing changes
111
+ - Main-session tokens live in the private team store, which sandboxed members cannot read. A member therefore cannot impersonate a main session over the socket
112
+ - Descendant non-escalation stays as it is: a member cannot grant its descendants more access than it has
113
+ - Provider credentials for sandboxed members come from the current owner's Pi login. After a takeover, the new owner brokers them under the same OS user
114
+
115
+ ## Changes needed before implementation
116
+
117
+ - **Team store schema 4.** Add `role` to members and migrate `root` to a main record. Replace `actor === "root"` checks with role checks. Store each main session's token
118
+ - **Coordinator.**
119
+ - Accept connections from main sessions that were not launched by the coordinator
120
+ - Add promote, join, leave, and the stop and resume of main sessions
121
+ - Add ownership takeover, and recompute descendant depths after a promotion
122
+ - **Launch environment.** Set `ACSTACK_AGENT_ROLE=member` for every member, including the terminal script's variable filter. Strip it on main-session launches
123
+ - **Extension.** Read the team entry at `session_start`. Add `/teams join`, `/teams leave`, and the **Promote to main** action. Open Herdr tabs, or use the tmux fallback
124
+ - **Docs.** Update `docs/usage.md`, `docs/operations.md`, and `docs/access.md`
125
+
126
+ ## Tests needed
127
+
128
+ - A launched member has `ACSTACK_AGENT_ROLE=member`, in both RPC and terminal modes
129
+ - A promoted session's environment has no `ACSTACK_AGENT_ROLE` and no `AC_TEAMS_*` variables
130
+ - Promotion keeps the session id, moves the file, updates the stored path, and writes the team entry. Pi's session listing then includes the session
131
+ - A sandboxed promotion is refused without confirmation and runs unsandboxed after it
132
+ - Joining refuses a duplicate session id, and refuses a session that leads another team with members
133
+ - A main session can stop and resume members and other main sessions. A member cannot act on a main session
134
+ - When the owner exits, another main session takes the lock. Busy members resume with the continue message, idle members stay idle, and members that needed input show **Needs input** again
135
+ - When no main session is running, the next one to open the team runs the same resume pass
136
+ - Resuming a promoted transcript with no coordinator offers takeover, and declining leaves team tools off
137
+ - Leaving returns in-progress tasks to todo and keeps descendants manageable
138
+ - A promotion interrupted after each step rolls back to a resumable member
139
+ - Schema 3 teams migrate to schema 4 with root as a main record
@@ -0,0 +1,34 @@
1
+ id: main-session
2
+ name: Main session
3
+ summary: >-
4
+ A Pi session the user started, joined to a team, or promoted. Every main
5
+ session controls every member and other main sessions, shares the team's
6
+ tasks and messages, and belongs to one team at a time.
7
+ kind: component
8
+ channels: [cli]
9
+ relationships:
10
+ - id: user-drives-main
11
+ from: user
12
+ to: main-session
13
+ direction: one-way
14
+ label: Promotes, joins, and leaves through /teams in
15
+ - id: main-uses-coordinator
16
+ from: main-session
17
+ to: coordinator
18
+ direction: one-way
19
+ label: Hosts as owner, or connects to as a client
20
+ - id: main-writes-transcript
21
+ from: main-session
22
+ to: transcripts
23
+ direction: one-way
24
+ label: Writes its conversation and team entry to
25
+ - id: guard-reads-main
26
+ from: read-guard
27
+ to: main-session
28
+ direction: one-way
29
+ label: Finds no member role in
30
+ - id: coordinator-launches-main
31
+ from: coordinator
32
+ to: main-session
33
+ direction: one-way
34
+ label: Relaunches promoted members as
@@ -0,0 +1,28 @@
1
+ id: member
2
+ name: Member
3
+ summary: >-
4
+ An agent-launched Pi process with ACSTACK_AGENT_ROLE=member and AC_TEAMS_*
5
+ variables. It controls only its descendants, and its transcript stays under
6
+ ac-teams/<team>/<member>/ until promotion.
7
+ kind: component
8
+ relationships:
9
+ - id: coordinator-runs-member
10
+ from: coordinator
11
+ to: member
12
+ direction: one-way
13
+ label: Launches, stops, and resumes
14
+ - id: member-uses-coordinator
15
+ from: member
16
+ to: coordinator
17
+ direction: one-way
18
+ label: Calls team tools through
19
+ - id: member-writes-transcript
20
+ from: member
21
+ to: transcripts
22
+ direction: one-way
23
+ label: Writes its conversation to
24
+ - id: guard-reads-member
25
+ from: read-guard
26
+ to: member
27
+ direction: one-way
28
+ label: Reads ACSTACK_AGENT_ROLE=member in
@@ -0,0 +1,17 @@
1
+ id: read-guard
2
+ name: ACStack read guard
3
+ summary: >-
4
+ ACStack's large-read hook. It blocks a large untargeted read once in main
5
+ agents only, and reads ACSTACK_AGENT_ROLE on every call to decide.
6
+ kind: external-system
7
+ relationships:
8
+ - id: guard-reads-member
9
+ from: read-guard
10
+ to: member
11
+ direction: one-way
12
+ label: Reads ACSTACK_AGENT_ROLE=member in
13
+ - id: guard-reads-main
14
+ from: read-guard
15
+ to: main-session
16
+ direction: one-way
17
+ label: Finds no member role in
@@ -0,0 +1,13 @@
1
+ id: team-store
2
+ name: Team store
3
+ summary: >-
4
+ Private team state under ~/.pi/ac-teams/teams/<team>/: members with their
5
+ role, tasks, messages, owner.lock, and each main session's connection
6
+ token. Sandboxed members cannot read it.
7
+ kind: store
8
+ relationships:
9
+ - id: coordinator-writes-store
10
+ from: coordinator
11
+ to: team-store
12
+ direction: one-way
13
+ label: Holds owner.lock and records roles in
@@ -0,0 +1,23 @@
1
+ id: transcripts
2
+ name: Pi transcripts
3
+ summary: >-
4
+ Standard Pi session files. Main sessions sit at the top level of the
5
+ project's session folder, where /resume finds them. Members sit under
6
+ ac-teams/<team>/<member>/, where only /teams finds them.
7
+ kind: store
8
+ relationships:
9
+ - id: main-writes-transcript
10
+ from: main-session
11
+ to: transcripts
12
+ direction: one-way
13
+ label: Writes its conversation and team entry to
14
+ - id: member-writes-transcript
15
+ from: member
16
+ to: transcripts
17
+ direction: one-way
18
+ label: Writes its conversation to
19
+ - id: coordinator-moves-transcript
20
+ from: coordinator
21
+ to: transcripts
22
+ direction: one-way
23
+ label: Moves promoted transcripts within
@@ -0,0 +1,17 @@
1
+ id: user
2
+ name: User
3
+ summary: >-
4
+ Starts main sessions and is the only party who can promote a member, join a
5
+ session to a team, or approve a sandboxed member's full access.
6
+ kind: actor
7
+ relationships:
8
+ - id: user-drives-main
9
+ from: user
10
+ to: main-session
11
+ direction: one-way
12
+ label: Promotes, joins, and leaves through /teams in
13
+ - id: user-uses-herdr
14
+ from: user
15
+ to: herdr
16
+ direction: one-way
17
+ label: Switches between main sessions in
@@ -48,6 +48,24 @@ Terminal UI checks used real native Pi processes in an isolated full-size Herdr
48
48
  - Task summary above the input, agent selector below status, native subcommand/name autocomplete, and same-PID/session opening by name or ID
49
49
  - Pi-reported model display names and changing context estimates in the live selector
50
50
 
51
+ ### Enforced access verification
52
+
53
+ Checks on 2026-11-02, Ubuntu 24.04, kernel 6.8, Docker 29.9.2 (rootful, default AppArmor and seccomp), Pi 1.4.0, member image `ac-teams-member:1.4.0-<hash>`:
54
+
55
+ - `test/sandbox.test.ts` with real containers for RPC and native (tmux) members: grants enforced read-only and read-write; host files, other transcripts, team state, the real `auth.json`, the Docker socket, and the tmux socket unreachable; only the selected provider's access token is present, without a refresh token, and a token change reaches the container in place; public HTTP and HTTPS work through the proxy with curl and Node; this host's addresses (including its public IP and Tailscale address), the Docker bridge, and cloud metadata are refused; no direct network, UDP, or host Unix socket; the start gate cannot be replayed; no capabilities, `NoNewPrivs`, private PIDs, read-only root, written files owned by the user; nested narrower launch works and escalation is refused; the native member's terminal is the container's own pty, and TIOCSTI did not inject; containers and member credentials are removed on close
56
+ - A grant swapped for a symlink between the checks and start is caught before any member code runs, and the container is removed; a writer of a repo and a reader of its subdirectory run together; resume gets a new gated container
57
+ - A refused second owner leaves live containers alone; after the coordinator is killed, the next owner removes the orphaned container; the enforced-member cap counts other teams' containers
58
+ - A real OpenAI OAuth member with the full installed extension set (billion-context active, ACStack, pi-web-access) answered through the proxy, ran curl (200), `web_search`, and `fetch_content` on a public documentation page; a member with `network.allow: []` still reached the model while curl to a public site failed; no refresh token or other provider in the container; network `none`, no ports, and no host listener changes
59
+
60
+ Anthropic, later on 2026-11-02 (Pi 1.4.0 for the first two checks; Pi 1.5.0 with member image `ac-teams-member:1.5.0-<hash>` for the third and for a full `npm test` rerun, 118 pass):
61
+
62
+ - A `claude-haiku-4-5` member with the full installed extension set ran curl (200), `web_search`, and `fetch_content`; with `network.allow: []` it still reached the model while curl failed; no refresh token or other provider in the container; network `none`, no ports
63
+ - A real Pi Main loading this branch from an isolated agent directory (access tokens only, no refresh tokens) launched an enforced Anthropic member with `network.allow: []` through Main's own credential code: the sandbox was available, the launch succeeded, and the member answered `curl=000`; no host listener changes and no leftover containers or member files
64
+
65
+ On the final tree with Pi 1.5.0, the OpenAI full-extension and `network.allow: []` runs passed again: curl 200, `web_search` and `fetch_content` ok, then curl `000` with the model still reachable. `npm test` passed 119 tests, and the Docker suite passed 10.
66
+
67
+ Not verified: an Anthropic Main session, because Main's own Anthropic request was refused as out of extra usage while the member's Haiku requests succeeded.
68
+
51
69
  Not verified on macOS, Windows, or network filesystems. The package is Linux-only because safe process identity uses `/proc`. No web dashboard, workflow DSL, daemon, or automatic merging is included.
52
70
 
53
71
  ## Stage releases for npm approval
@@ -1,6 +1,6 @@
1
1
  # Operate ac-teams
2
2
 
3
- ac-teams runs on Linux with Node 22.19 or newer and an authenticated `pi` executable on `PATH`. Interactive roots also require `tmux`: each team owns a private server and native member terminals. Headless roots retain RPC members. Both share your OS user's access, not a sandbox.
3
+ ac-teams runs on Linux with Node 22.19 or newer and an authenticated `pi` executable on `PATH`. Interactive roots also require `tmux`: each team owns a private server and native member terminals. Headless roots retain RPC members. Both share your OS user's access unless a member is launched with [enforced access](access.md).
4
4
 
5
5
  ## Save project trust before launching
6
6
 
@@ -15,7 +15,7 @@ Default: at most **10 live descendants**, counting starting, busy, idle, and sto
15
15
  Root policy precedence, from lowest to highest:
16
16
 
17
17
  1. Built-in defaults
18
- 2. `~/.acstack/ac-teams/config.json`
18
+ 2. `~/.pi/ac-teams/config.json`
19
19
  3. `.pi/ac-teams.json`, only with `--teams-project-config`
20
20
  4. Explicit `--teams-limit` and `--teams-depth` flags
21
21
 
@@ -33,7 +33,7 @@ Other delegation tools remain outside these limits. See [companion tools](usage.
33
33
 
34
34
  ## Display preferences are global
35
35
 
36
- `/teams config` saves task-summary and agent-selector visibility to `~/.acstack/ac-teams/display.json`:
36
+ `/teams config` saves task-summary and agent-selector visibility to `~/.pi/ac-teams/display.json`:
37
37
 
38
38
  ```json
39
39
  {"tasks":true,"agents":true}
@@ -51,6 +51,8 @@ This is snapshot-based cleanup, not OS containment. Detached or uncaptured desce
51
51
 
52
52
  The interactive panel can instead quietly resume a saved member session without inventing a new brief. Feedback then arrives through the durable team mailbox.
53
53
 
54
+ Stopping a main session ends its Pi process; an exiting coordinator hands over first. Resuming another main session relaunches it in its own terminal.
55
+
54
56
  Root session switching, forking, and tree traversal are blocked while members are live. A member cannot switch away from its team binding. Stop members explicitly before replacing the root session.
55
57
 
56
58
  ### Exit confirmation needs a Pi hook
@@ -59,6 +61,12 @@ Exit confirmation requires Pi's `session_before_exit` hook. The prerequisite is
59
61
 
60
62
  With that hook, native exit asks before stopping live members; cancellation leaves them running. Without it, shutdown performs cleanup but cannot ask first. Signals bypass confirmation.
61
63
 
64
+ ### Coordinator handover
65
+
66
+ The main session holding `owner.lock` runs the coordinator. Before it exits it records whether each member was busy, idle, or waiting for input, then stops the members. Another connected main session takes the lock within seconds and runs the resume pass: busy members resume and are told their process restarted during a handover, idle members resume idle, and members waiting for input resume and ask again. The same pass runs when a main session opens a team with no coordinator running.
67
+
68
+ A main session that crashes is marked interrupted; any main session can resume it. Raising the member limit above 10 requires the coordinator's own session, because the confirmation prompt runs there.
69
+
62
70
  ### Crash recovery is best effort
63
71
 
64
72
  If the root crashes, closing its owned pipes and losing the coordinator connection makes ordinary RPC children shut down. Native members also monitor the authenticated coordinator connection and exit after it is lost. This passed SIGTERM and SIGKILL probes with native child and grandchild processes.
@@ -75,11 +83,12 @@ Missing targets, dirty work, and uncertain/squashed integration retain the tree.
75
83
 
76
84
  ## Local records contain sensitive project data
77
85
 
78
- Each root session uses `~/.acstack/ac-teams/teams/<session-id>/`:
86
+ Each root session uses `~/.pi/ac-teams/teams/<session-id>/`:
79
87
 
80
88
  - `state.json` contains membership, policy, tasks, messages, claims, and notification cursors
81
- - `sessions/` contains native Pi-managed member transcripts
82
- - `owner.lock` and `coordinator.sock` coordinate the running root
89
+ - Member transcripts live under Main's session directory in `ac-teams/<team>/<member>/`, normally `~/.pi/agent/sessions/<project>/ac-teams/...`; Pi's `/resume` lists only top-level sessions, so use `/teams` to find them
90
+ - `owner.lock` and `coordinator.sock` coordinate the main session running the coordinator; other main sessions authenticate with tokens stored in `state.json`
91
+ - Promoted members' transcripts move to `<session folder>/` beside other top-level sessions, and their `~/.pi/ac-teams/agents/<id>` folder is removed
83
92
  - Interactive teams also own `t.sock`, the private tmux socket, and per-member terminal environment files; shutdown removes those files
84
93
 
85
94
  New members also store their descriptive names and optional model/context reports. When an existing team opens, unnamed members receive stable, collision-free names such as `teammate-1`; their IDs and sessions do not change.
@@ -88,7 +97,9 @@ State schema 3 adds stable task numbers and short labels. Opening a schema 1 or
88
97
 
89
98
  Schema 1 also migrates progress: `pending` becomes `todo`, `claimed` becomes `in_progress`, and `completed` becomes `done`. Migration writes atomically and rejects invalid snapshots without replacement. Older ac-teams code cannot reopen schema 3; stop old sessions before updating.
90
99
 
91
- Only the root writes state. Mutations validate a copy, flush a temporary snapshot, atomically rename it, and flush the directory before returning success. An exclusive process-identity lock prevents concurrent owners. Private team directories use 0700; state files and the authenticated Unix socket use 0600. Uncertain locks fail closed.
100
+ State schema 4 adds a role to each record: `main` or `member`. Opening a schema 3 team makes the root record a main session and every other record a member. A record without a role has left the team. A promotion records each step, so a failure rolls the member back to a stopped member with its original transcript path.
101
+
102
+ Only the coordinator writes state. Mutations validate a copy, flush a temporary snapshot, atomically rename it, and flush the directory before returning success. An exclusive process-identity lock prevents concurrent owners. Private team directories use 0700; state files and the authenticated Unix socket use 0600. Uncertain locks fail closed.
92
103
 
93
104
  This is a small-team store: each mutation rewrites the snapshot, and search scans local history. There is no database, compaction, retention policy, automatic deletion, or deduplication of repeated mutation requests. Inspect state before retrying an ambiguous failed request.
94
105
 
package/docs/usage.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Use ac-teams
2
2
 
3
- ac-teams provides three tools without prescribing a workflow. Your Pi session is the root; its child agents are members. The root opens coordination state on the first team request.
3
+ ac-teams provides three tools without prescribing a workflow. Your Pi session is a main session; the agents it launches are members. The first main session opens coordination state on the first team request. See [main sessions](#main-sessions-share-one-team) for adding more.
4
4
 
5
5
  | Tool | Actions | Main rule |
6
6
  | --- | --- | --- |
@@ -8,7 +8,7 @@ ac-teams provides three tools without prescribing a workflow. Your Pi session is
8
8
  | `team_task` | `create`, `list`, `read`, `edit`, `claim`, `start`, `release`, `complete`, `reopen` | Claim reserves work; dependencies gate starting, not ownership |
9
9
  | `team_message` | `send`, `history`, `search` | Recipients select who receives notification, not who can read |
10
10
 
11
- In interactive Pi, `/teams` or `/teams panel` opens the team panel. `/teams teammate <name-or-id>` opens a live teammate directly; autocomplete suggests subcommands and teammate names. You can also enter an exact teammate ID. The older `/teams open <id>` form remains supported. Headless roots retain the status listing. `/teams stop` stops every descendant. See [operations](operations.md) for limits, recovery, and worktree cleanup.
11
+ In interactive Pi, `/teams` or `/teams panel` opens the team panel. `/teams teammate <name-or-id>` opens a live teammate directly; autocomplete suggests subcommands and teammate names. You can also enter an exact teammate ID. The older `/teams open <id>` form remains supported. Headless roots retain the status listing. `/teams stop` stops every member; main sessions keep running. `/teams join` and `/teams leave` change which team this session belongs to. See [operations](operations.md) for limits, recovery, and worktree cleanup.
12
12
 
13
13
  ## Navigate without stopping members
14
14
 
@@ -38,6 +38,17 @@ The `+N lines` hint shows the hidden detail count. Ctrl+O expands the full reque
38
38
 
39
39
  Team notification messages remain compact; Ctrl+O expands their labeled fields. UI state colors do not replace the underlying progress, ownership, or dependency fields.
40
40
 
41
+ ## Main sessions share one team
42
+
43
+ A team can have several main sessions, each a top-level Pi session a person drives. Members are the agents they launch. Every main session controls every member and can stop or resume other main sessions; a member controls only its own descendants and never a main session.
44
+
45
+ - **Promote:** On the panel's Teammates tab, `P` stops a member and relaunches it as a main session. It keeps its session ID and history, its transcript moves to the normal Pi session folder so `/resume` lists it, and it stays in the team. Under Herdr it opens in a new tab of the workspace for its worktree; otherwise it runs in its own tmux server and ac-teams prints the attach command. A member with enforced access asks first, because the promoted session runs without the container.
46
+ - **Join:** `/teams join` lists the teams in this project and adds this session to the chosen one as a main session, then posts a summary of open tasks, owners, and recent messages. It refuses a session already in that team, or one already in a team with other members.
47
+ - **Leave:** `/teams leave` returns this session's in-progress tasks to `todo`, clears their ownership, and drops its main-session role; its record keeps its history. The last main session cannot leave while members are live.
48
+ - **Resume:** A session remembers its team in its transcript. Resuming it reconnects to the team; if no coordinator is running, ac-teams offers to run it here. Declining turns the team tools off for this session.
49
+
50
+ One main session runs the coordinator; the others connect to it. When that session exits, another main session takes over automatically and resumes the members that were working. See [operations](operations.md#coordinator-handover).
51
+
41
52
  ## Show tasks and configure the displays
42
53
 
43
54
  The task summary sits above the input editor; the agent selector sits below the editor. The task summary shows completed/total counts and up to five numbered task labels, ordered by:
@@ -133,6 +144,6 @@ Messages survive crashes; exactly-once delivery or model execution does not. Avo
133
144
 
134
145
  ac-teams works alone. ACStack adds planning, review, and shipping skills; Herdr adds terminal tabs, panes, and visible root sessions. These are companion capabilities, not automatic integrations. ac-teams does not open a Herdr pane for each member. Interactive members use a team-private tmux server and open in the current terminal when selected; headless roots use RPC members.
135
146
 
136
- Other delegation tools can coexist. ac-teams warns when `Agent`, `SubagentWorkflow`, or `subagent` is active, but does not disable them. Its limits cover only members launched through `team_agent`. Use Pi's resource settings to disable other launchers if you want one team authority. Shell access and same-user extensions are not sandboxed.
147
+ Other delegation tools can coexist. ac-teams warns when `Agent`, `SubagentWorkflow`, or `subagent` is active, but does not disable them. Its limits cover only members launched through `team_agent`. Use Pi's resource settings to disable other launchers if you want one team authority. Shell access and same-user extensions are not sandboxed unless a member is launched with [enforced access](access.md).
137
148
 
138
149
  [Back to README](../README.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ac-teams",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "Small, persistent teams for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/access.ts ADDED
@@ -0,0 +1,175 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
4
+ import { homedir, totalmem } from "node:os";
5
+ import { stateRoot } from "./config.ts";
6
+ import { assertNetworkNarrower, validatePolicy, type NetworkPolicy } from "./egress.ts";
7
+ import { dirname, join, resolve } from "node:path";
8
+
9
+ /** Canonical directory grants; write implies read. Undefined access means an unsandboxed member. */
10
+ export interface Access { read: string[]; write: string[]; network: NetworkPolicy }
11
+ export interface AccessRequest { read?: string[]; write?: string[]; network?: NetworkPolicy }
12
+ export interface SandboxStatus { available: boolean; backend: "docker"; reason?: string }
13
+
14
+ export const within = (path: string, root: string) => path === root || path.startsWith(root === "/" ? "/" : root + "/");
15
+ const inside = (path: string, roots: string[]) => roots.some(root => within(path, root));
16
+ export const readable = (access: Access, path: string) => inside(path, [...access.read, ...access.write]);
17
+ export const writable = (access: Access, path: string) => inside(path, access.write);
18
+
19
+ /** Pi's agent directory and ac-teams state hold credentials, transcripts, and coordinator tokens. */
20
+ export const privateRoots = () => [resolve(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent")), stateRoot()];
21
+
22
+ export function resolveAccess(request: AccessRequest, base: string): Access {
23
+ const canonical = (paths: string[] = []) => [...new Set(paths.map(path => {
24
+ let real: string;
25
+ try { real = realpathSync(resolve(base, path)); } catch { throw new Error(`Access path does not exist: ${path}`); }
26
+ if (!statSync(real).isDirectory()) throw new Error(`Access path must be a directory: ${path}`);
27
+ // A symlink in the path would be re-resolved by Docker at start; a sandboxed writer could have planted or could swap it.
28
+ if (real !== resolve(base, path)) throw new Error(`Access paths must be canonical, without symlinks: ${path} resolves to ${real}`);
29
+ // Docker's --mount syntax has no escaping, so a comma would inject mount options.
30
+ if (/[,\n]/.test(real)) throw new Error(`Access paths cannot contain commas or newlines: ${real}`);
31
+ // These would replace the sandbox's private /proc, /dev, /tmp, or home, or expose host sockets and credentials.
32
+ for (const unsafe of ["/proc", "/dev", "/sys", "/run"]) if (within(real, unsafe)) throw new Error(`Access to ${real} is not allowed`);
33
+ for (const covered of ["/tmp", "/run", "/var/run", homedir()]) if (within(covered, real)) throw new Error(`Access to ${real} would expose ${covered}; grant a narrower directory`);
34
+ for (const root of privateRoots()) if (within(real, root) || within(root, real)) throw new Error(`Access to ${real} would expose private Pi or ac-teams state in ${root}`);
35
+ return real;
36
+ }))].sort();
37
+ const write = canonical(request.write);
38
+ return { read: canonical(request.read).filter(path => !inside(path, write)), write, network: validatePolicy(request.network ?? {}) };
39
+ }
40
+
41
+ /** Throws unless child grants are the same as or narrower than the actor's; an unsandboxed actor may grant anything. */
42
+ export function assertNarrower(actor: Access | undefined, child: Access | undefined): void {
43
+ if (!actor) return;
44
+ if (!child) throw new Error("A sandboxed member cannot launch or resume an unsandboxed member");
45
+ for (const path of child.write) if (!writable(actor, path)) throw new Error(`Write access to ${path} exceeds the requesting member's access`);
46
+ for (const path of child.read) if (!readable(actor, path)) throw new Error(`Read access to ${path} exceeds the requesting member's access`);
47
+ assertNetworkNarrower(actor.network, child.network);
48
+ }
49
+
50
+ /** Host paths a member mounts, and the subset it can write. */
51
+ export interface MountSet { sources: string[]; writes: string[] }
52
+ /** Grants are identity-checked at start, so only a member's other mounts count as sources; its writable grants are its writes. */
53
+ export const mountSet = (mounts: { source: string; rw: boolean }[], grants: string[]): MountSet => ({
54
+ sources: mounts.filter(mount => !grants.includes(mount.source)).map(mount => mount.source),
55
+ writes: mounts.filter(mount => mount.rw && grants.includes(mount.source)).map(mount => mount.source) });
56
+ /**
57
+ * Docker resolves bind sources when a container starts. A sandboxed writer can replace any entry strictly below its
58
+ * writable directory, so no member may mount a path strictly inside another live member's writable directory, in either
59
+ * direction. Equal paths are safe: a writer cannot replace its own mount point. A member's own non-grant mounts (runtime,
60
+ * bridge) must not sit inside its own writable directory either, or it could swap them before its next start.
61
+ */
62
+ export function assertStableGrants(mine: MountSet, others: MountSet[]): void {
63
+ const below = (set: MountSet, writer: MountSet) => set.sources.find(path => writer.writes.some(root => path !== root && within(path, root)));
64
+ for (const other of [...others, mine]) {
65
+ const path = below(mine, other) ?? below(other, mine);
66
+ if (path) throw new Error(`Mount ${path} lies strictly inside a sandboxed member's writable directory; grant that directory or a path outside it`);
67
+ }
68
+ }
69
+
70
+ /** Pi agent-directory entries members need: installed packages, extensions, themes, skills, prompts, and settings Pi reads at startup. */
71
+ export const agentRuntime = ["npm", "extensions", "themes", "skills", "prompts", "AGENTS.md", "keybindings.json", "acstack.json", "models-store.json"];
72
+ export const generatedAgentFiles = ["settings.json", "auth.json", "trust.json", "web-search.json"];
73
+
74
+ export function packageRoot(file: string): string {
75
+ for (let dir = dirname(realpathSync(file)); ; dir = dirname(dir)) {
76
+ if (existsSync(join(dir, "package.json"))) return dir;
77
+ if (dir === dirname(dir)) throw new Error(`No package root for ${file}`);
78
+ }
79
+ }
80
+
81
+ // Container memory caps; the aggregate guard keeps the sum of caps within 75% of host RAM.
82
+ // shortcut: fixed per-member cap, add a setting if real workloads need more.
83
+ export const memberMemory = 2 * 1024 ** 3;
84
+ export const memberPids = 1024;
85
+ export const maxSandboxed = () => Math.max(1, Math.floor(totalmem() * 0.75 / memberMemory));
86
+
87
+ const dockerfile = new URL("./sandbox.Dockerfile", import.meta.url);
88
+ /** Image tag tied to the host Pi version and this Dockerfile, so members run the same Pi as Main. */
89
+ export function memberImage(piVersion: string): { tag: string; build: string } {
90
+ const hash = createHash("sha256").update(readFileSync(dockerfile)).digest("hex").slice(0, 12);
91
+ const tag = `ac-teams-member:${piVersion}-${hash}`, file = dockerfile.pathname;
92
+ return { tag, build: `docker build -t ${tag} --build-arg PI_VERSION=${piVersion} -f ${file} ${dirname(file)}` };
93
+ }
94
+
95
+ export function sandboxStatus(image: { tag: string; build: string }, docker = "docker"): SandboxStatus {
96
+ const status = (reason?: string): SandboxStatus => ({ available: !reason, backend: "docker", ...(reason ? { reason } : {}) });
97
+ if (process.platform !== "linux") return status("Enforced access requires Linux");
98
+ const probe = spawnSync(docker, ["image", "inspect", "--format", "{{json .Config.Volumes}}", image.tag], { encoding: "utf8", timeout: 10000 });
99
+ if (probe.error) return status(`Docker is unavailable: ${probe.error.message}`);
100
+ if (probe.status !== 0) return status(/No such image/i.test(probe.stderr) ? `Member image ${image.tag} is missing; build it with: ${image.build}` : `Docker cannot be used: ${probe.stderr.trim().slice(0, 300)}`);
101
+ if (!["null", "{}"].includes(probe.stdout.trim())) return status(`Member image ${image.tag} declares volumes; rebuild it from the shipped Dockerfile`);
102
+ return status();
103
+ }
104
+
105
+ export interface SandboxPlan {
106
+ name: string; labels: Record<string, string>; image: string; access: Access; cwd: string; home: string; terminal: boolean;
107
+ agentDir: string; configDir: string; sessionDir: string; coordinatorSocket: string; egressSocket: string;
108
+ runtime: string[]; launch: string; bridge: string;
109
+ }
110
+ export interface Mount { source: string; target: string; readonly: boolean }
111
+ /** Every host path a member container sees. Nothing else on the host is mounted. */
112
+ export function sandboxMounts(plan: SandboxPlan): Mount[] {
113
+ const mounts: Mount[] = [
114
+ ...plan.access.read.map(path => ({ source: path, target: path, readonly: true })),
115
+ ...plan.access.write.map(path => ({ source: path, target: path, readonly: false })),
116
+ // Runtime code stays read-only even inside a writable grant, because unsandboxed processes load it too.
117
+ ...plan.runtime.map(path => ({ source: path, target: path, readonly: true })),
118
+ ...agentRuntime.map(name => join(plan.agentDir, name)).filter(path => existsSync(path)).map(path => ({ source: path, target: path, readonly: true })),
119
+ ...generatedAgentFiles.map(name => ({ source: join(plan.configDir, name), target: join(plan.agentDir, name), readonly: true })),
120
+ { source: plan.launch, target: plan.launch, readonly: true },
121
+ { source: plan.sessionDir, target: plan.sessionDir, readonly: false },
122
+ { source: plan.coordinatorSocket, target: plan.coordinatorSocket, readonly: false },
123
+ { source: plan.egressSocket, target: plan.egressSocket, readonly: false },
124
+ ];
125
+ return mounts;
126
+ }
127
+
128
+ export function dockerCreateArgs(plan: SandboxPlan): string[] {
129
+ const uid = process.getuid!(), gid = process.getgid!();
130
+ const args = ["create", "--name", plan.name, "--interactive", ...(plan.terminal ? ["--tty"] : []), "--init",
131
+ "--network", "none", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--read-only", "--restart", "no",
132
+ "--user", `${uid}:${gid}`, "--memory", String(memberMemory), "--memory-swap", String(memberMemory), "--pids-limit", String(memberPids),
133
+ "--log-driver", "none", "--hostname", "ac-teams",
134
+ "--tmpfs", "/tmp:rw,nosuid,nodev,size=512m", "--tmpfs", `${plan.home}:rw,nosuid,nodev,size=256m,uid=${uid},gid=${gid},mode=0700`,
135
+ // Pi writes lock files and caches in its agent directory; this private scratch copy hides the real one.
136
+ "--tmpfs", `${plan.agentDir}:rw,nosuid,nodev,size=64m,uid=${uid},gid=${gid},mode=0700`];
137
+ for (const [key, value] of Object.entries(plan.labels)) args.push("--label", `${key}=${value}`);
138
+ // Other coordinators use this to tell grants (identity-checked at start) from runtime mounts.
139
+ args.push("--label", `ac-teams.grants=${JSON.stringify([...plan.access.read, ...plan.access.write])}`);
140
+ for (const mount of sandboxMounts(plan)) args.push("--mount", `type=bind,source=${mount.source},target=${mount.target}${mount.readonly ? ",readonly" : ""}`);
141
+ args.push("--env", "PATH=/usr/local/bin:/usr/bin:/bin", "--env", `HOME=${plan.home}`, "--env", "TMPDIR=/tmp",
142
+ "--workdir", plan.cwd, plan.image, "node", plan.bridge, plan.launch);
143
+ return args;
144
+ }
145
+
146
+ /** Checks docker inspect output against the policy; any problem means the container must not start. */
147
+ export function containerProblems(inspect: any, plan: SandboxPlan, started = false): string[] {
148
+ const problems: string[] = [], host = inspect?.HostConfig ?? {}, config = inspect?.Config ?? {};
149
+ const expect = (ok: boolean, problem: string) => { if (!ok) problems.push(problem); };
150
+ expect(host.NetworkMode === "none", `network mode ${host.NetworkMode}`);
151
+ expect(!Object.keys(host.PortBindings ?? {}).length && !host.PublishAllPorts && !Object.keys(config.ExposedPorts ?? {}).length, "ports are published or exposed");
152
+ expect(host.Privileged === false, "privileged");
153
+ expect(JSON.stringify(host.CapDrop) === '["ALL"]' && !(host.CapAdd ?? []).length, "capabilities are not all dropped");
154
+ const security: string[] = host.SecurityOpt ?? [];
155
+ expect(security.length > 0 && security.every(option => /^no-new-privileges(:true)?$/.test(option)), "security options differ from no-new-privileges");
156
+ expect(host.ReadonlyRootfs === true, "root filesystem is writable");
157
+ expect(["no", ""].includes(host.RestartPolicy?.Name ?? ""), "restart policy is set");
158
+ expect(host.LogConfig?.Type === "none", "container logging is enabled");
159
+ expect(!host.PidMode && ["", "private"].includes(host.IpcMode ?? "") && !host.UTSMode && !host.UsernsMode && !host.CgroupnsMode?.startsWith?.("host"), "host namespaces are shared");
160
+ expect(!(host.Devices ?? []).length && !(host.DeviceRequests ?? []).length && !(host.GroupAdd ?? []).length && !(host.VolumesFrom ?? []).length, "devices, groups, or volumes-from are added");
161
+ expect(host.Memory === memberMemory && host.PidsLimit === memberPids, "resource limits are missing");
162
+ expect(config.User === `${process.getuid!()}:${process.getgid!()}`, `user ${config.User}`);
163
+ expect(!Object.keys(config.Volumes ?? {}).length, "the image declares volumes");
164
+ const allowed = new Map(sandboxMounts(plan).map(mount => [mount.target, mount]));
165
+ const mounts: any[] = inspect?.Mounts ?? [];
166
+ expect(mounts.length === allowed.size, `expected ${allowed.size} mounts, found ${mounts.length}`);
167
+ for (const mount of mounts) {
168
+ const wanted = allowed.get(mount.Destination);
169
+ expect(mount.Type === "bind" && !!wanted && mount.Source === wanted.source && mount.RW === !wanted.readonly, `unexpected mount ${mount.Type} ${mount.Source} -> ${mount.Destination}`);
170
+ expect(!/docker\.sock$/.test(mount.Source ?? ""), "the Docker socket is mounted");
171
+ }
172
+ expect(JSON.stringify(Object.keys(host.Tmpfs ?? {}).sort()) === JSON.stringify(["/tmp", plan.home, plan.agentDir].sort()), "unexpected tmpfs mounts");
173
+ if (started) expect(inspect?.NetworkSettings && !Object.keys(inspect.NetworkSettings.Ports ?? {}).length, "ports are bound after start");
174
+ return problems;
175
+ }
package/src/commands.ts CHANGED
@@ -3,7 +3,7 @@ import { memberName, type Member } from "./types.ts";
3
3
  import { cleanText } from "./render.ts";
4
4
 
5
5
  type NamedMember = Pick<Member, "id" | "name" | "status">;
6
- const actions = ["panel", "teammate", "config", "back", "stop", "status"];
6
+ const actions = ["panel", "teammate", "config", "back", "stop", "status", "join", "leave"];
7
7
 
8
8
  export function resolveTeammate(members: NamedMember[], value: string): string | undefined {
9
9
  const key = value === "main" ? "root" : value;