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.
package/src/mains.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ const exec = promisify(execFile);
5
+ /** Pi custom entry in a main session's transcript: which team it belongs to. Holds no secrets. */
6
+ export const teamEntryType = "ac-teams-team";
7
+ export interface TeamEntry { team: string; member: string; left?: boolean }
8
+ export const handoverPrompt = "Your process was restarted during a coordinator handover. Check your last steps and continue your current task.";
9
+
10
+ /** A main session's environment: no member identity, member role, or member Herdr opt-out. */
11
+ export function mainEnv(env: NodeJS.ProcessEnv = process.env): Record<string, string> {
12
+ const clean: Record<string, string> = {};
13
+ for (const [key, value] of Object.entries(env)) {
14
+ if (value === undefined || key === "ACSTACK_AGENT_ROLE" || key.startsWith("AC_TEAMS_") || (key === "HERDR_ENV" && value === "0") || key === "TMUX" || key === "TMUX_PANE") continue;
15
+ clean[key] = value;
16
+ }
17
+ return clean;
18
+ }
19
+
20
+ export interface MainLaunch { id: string; name: string; cwd: string; command: string[]; env: Record<string, string>; socket: string }
21
+ /**
22
+ * Opens a main session where the user can switch to it: its own Herdr tab in the workspace for its worktree, or else a
23
+ * tmux session on a socket of its own, so the coordinator owner's tmux kill-server cannot reach it. Returns a user notice.
24
+ */
25
+ export async function launchMain(spec: MainLaunch): Promise<string> {
26
+ if (spec.env.HERDR_ENV === "1") {
27
+ const herdr = async (...args: string[]) => JSON.parse((await exec("herdr", args, { env: spec.env, timeout: 10000, maxBuffer: 4 * 1024 * 1024 })).stdout);
28
+ const workspaces = (await herdr("workspace", "list")).result?.workspaces as { workspace_id: string; worktree?: { checkout_path?: string } }[] | undefined;
29
+ const workspace = workspaces?.find(entry => entry.worktree?.checkout_path === spec.cwd);
30
+ const created = workspace
31
+ ? await herdr("tab", "create", "--workspace", workspace.workspace_id, "--cwd", spec.cwd, "--label", spec.name, "--focus")
32
+ : await herdr("workspace", "create", "--cwd", spec.cwd, "--label", spec.name, "--focus");
33
+ const pane = created.result?.root_pane?.pane_id;
34
+ if (typeof pane !== "string") throw new Error("Herdr did not report the new tab's pane");
35
+ // Herdr types the command into the pane's shell, so each word is quoted for it.
36
+ await exec("herdr", ["pane", "run", pane, spec.command.map(word => `'${word.replaceAll("'", "'\\''")}'`).join(" ")], { env: spec.env, timeout: 10000 });
37
+ return `${spec.name} opened in its own Herdr tab`;
38
+ }
39
+ await exec("tmux", ["-S", spec.socket, "new-session", "-d", "-s", spec.id, "-c", spec.cwd, "--", ...spec.command], { env: spec.env, timeout: 10000 });
40
+ return `${spec.name} runs in tmux; attach with: tmux -S ${spec.socket} attach -t ${spec.id}`;
41
+ }
package/src/panel.ts CHANGED
@@ -93,7 +93,9 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
93
93
  try {
94
94
  const member = snapshot.members.find(member => member.id === id);
95
95
  if (member && ["stopped", "interrupted"].includes(member.status)) await host.resume(id);
96
- done(id);
96
+ // Main sessions run in their own terminals; resuming one opens it there.
97
+ if (member?.role === "main" && id !== host.self) receipt = `${name(id)} runs in its own terminal`;
98
+ else done(id);
97
99
  } catch (error) { receipt = `Could not open ${name(id)}: ${clean(String(error))}`; }
98
100
  finally { opening = false; refresh(); }
99
101
  }
@@ -237,6 +239,7 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
237
239
  heading("Message with full context"), "Open the native Pi session to read its conversation and type your message there.", "",
238
240
  fg("accent", member && ["stopped", "interrupted"].includes(member.status) ? "Enter Resume and open" : "Enter Open full session"),
239
241
  fg("muted", "No separate message composer."),
242
+ ...(!host.child && member?.role === "member" ? ["", fg("accent", "P Promote to main session"), fg("muted", "Stops it and reopens its transcript as a main session in its own terminal.")] : []),
240
243
  ], width);
241
244
  }
242
245
 
@@ -294,6 +297,11 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
294
297
  } else if (data.toLowerCase() === "g" && tab === 0 && !detail) { graph = !graph; scroll = 0; }
295
298
  else if (data.toLowerCase() === "n") begin("new");
296
299
  else if (data.toLowerCase() === "c" && tab === 0) begin("feedback");
300
+ else if (data.toLowerCase() === "p" && tab === 1 && !host.child) {
301
+ const id = memberIds()[memberIndex];
302
+ if (snapshot.members.find(member => member.id === id)?.role !== "member") receipt = "Only a member can be promoted.";
303
+ else { done(undefined); void host.promote(id).catch(error => ctx.ui.notify(`Promotion failed: ${clean(String(error))}`, "error")); return; }
304
+ }
297
305
  else if (matchesKey(data, "enter") || data.toLowerCase() === "o") {
298
306
  if (tab === 1 || data.toLowerCase() === "o") {
299
307
  const id = tab === 1 ? memberIds()[memberIndex] : tab === 0 ? selectedTask()?.owner : events[eventIndex]?.from;
@@ -0,0 +1,33 @@
1
+ // Trusted bootstrap inside a member container. It exposes the parent's egress proxy on loopback, then waits for the
2
+ // parent to verify the container's mounts before running any member code (Pi, extensions, project files).
3
+ import { spawn } from "node:child_process";
4
+ import { readFileSync } from "node:fs";
5
+ import { request } from "node:http";
6
+ import { connect, createServer } from "node:net";
7
+ import { constants } from "node:os";
8
+
9
+ const launch = JSON.parse(readFileSync(process.argv[2], "utf8"));
10
+ const fail = message => { console.error(`ac-teams sandbox bridge: ${message}`); process.exit(1); };
11
+ const server = createServer(client => {
12
+ const upstream = connect(launch.socket);
13
+ client.pipe(upstream).pipe(client);
14
+ client.on("error", () => upstream.destroy());
15
+ upstream.on("error", () => client.destroy());
16
+ });
17
+ server.on("error", error => fail(error.message));
18
+ server.listen(launch.port, "127.0.0.1", () => {
19
+ const gate = request({ socketPath: launch.socket, path: "http://ac-teams.invalid/start", method: "GET" }, response => {
20
+ let body = "";
21
+ response.on("data", chunk => { body += chunk; });
22
+ response.on("end", () => {
23
+ if (response.statusCode !== 200 || body !== "go") fail("the parent did not release this member");
24
+ const child = spawn(launch.command, launch.args, { env: launch.env, stdio: "inherit" });
25
+ for (const signal of ["SIGTERM", "SIGHUP"]) process.on(signal, () => child.kill(signal));
26
+ process.on("SIGINT", () => {}); // The terminal already delivers Ctrl+C to Pi's process group.
27
+ child.on("error", error => fail(error.message));
28
+ child.on("exit", (code, signal) => process.exit(code ?? (signal ? 128 + (constants.signals[signal] ?? 0) : 1)));
29
+ });
30
+ });
31
+ gate.on("error", error => fail(error.message));
32
+ gate.end();
33
+ });
@@ -0,0 +1,8 @@
1
+ # Image for enforced ac-teams members. Host code, grants, and Pi packages are mounted at run time.
2
+ FROM node:22-bookworm-slim@sha256:4b150b48b4116e2a22f2c39cc27025fbf4f153e2078fd3b14d159fdb5c7279da
3
+ ARG PI_VERSION
4
+ RUN apt-get update \
5
+ && apt-get install -y --no-install-recommends bash ca-certificates curl git ripgrep \
6
+ && rm -rf /var/lib/apt/lists/* \
7
+ && npm install -g --ignore-scripts "@earendil-works/pi-coding-agent@${PI_VERSION}" \
8
+ && npm cache clean --force
package/src/schemas.ts CHANGED
@@ -13,6 +13,13 @@ export const AgentSchema = Type.Object({
13
13
  worktree: Type.Optional(Type.Boolean()), base: Type.Optional(id), target: Type.Optional(id),
14
14
  limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
15
15
  depth: Type.Optional(Type.Integer({ minimum: 0, maximum: 10 })),
16
+ access: Type.Optional(Type.Object({
17
+ read: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 4096 }), { maxItems: 32 })),
18
+ write: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 4096 }), { maxItems: 32 })),
19
+ network: Type.Optional(Type.Object({
20
+ allow: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 260 }), { maxItems: 64, description: "Hosts the member may reach through its proxy: host or *.host (subdomains), optionally :port; without a port, 80 and 443. [] allows only the model provider. Omit allow for any public destination." })),
21
+ }, { additionalProperties: false, description: "Omit to keep the requester's network access. Children can only narrow it. The model provider stays reachable." })),
22
+ }, { additionalProperties: false, description: "Launch only: OS-enforced directory access (Docker container). Paths are absolute or cwd-relative directories; write implies read and permits changing those live files; everything else on the host is hidden, and outbound access goes through a proxy that reaches only public addresses. Omit to inherit the requesting member's access; an unsandboxed requester then launches an unsandboxed member. Children can only narrow access. Launch fails if enforcement is unavailable; check team_agent list/limits sandbox first." })),
16
23
  }, { additionalProperties: false });
17
24
  export const TaskSchema = Type.Object({
18
25
  revision: Type.Optional(Type.Integer({ minimum: 1 })), acceptance: Type.Optional(Type.Boolean()),
@@ -30,8 +37,8 @@ export const MessageSchema = Type.Object({
30
37
  after: Type.Optional(Type.Integer({ minimum: 0 })), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
31
38
  }, { additionalProperties: false });
32
39
  const UserActionSchema = Type.Object({
33
- action: Type.Union([Type.Literal("task_request"), Type.Literal("feedback"), Type.Literal("resume")]),
34
- id: Type.Optional(id), text: Type.Optional(text),
40
+ action: Type.Union([Type.Literal("task_request"), Type.Literal("feedback"), Type.Literal("resume"), Type.Literal("promote"), Type.Literal("leave")]),
41
+ id: Type.Optional(id), text: Type.Optional(text), confirmed: Type.Optional(Type.Boolean()),
35
42
  model: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
36
43
  thinking: AgentSchema.properties.thinking,
37
44
  }, { additionalProperties: false });
@@ -56,5 +63,6 @@ export function validate(operation: string, value: unknown): void {
56
63
  const args = value as { action: string; name?: string };
57
64
  if (args.action === "launch" && !args.name) throw new Error("A descriptive teammate name is required on launch");
58
65
  if (args.action !== "launch" && args.name !== undefined) throw new Error("A teammate name can only be set on launch");
66
+ if (args.action !== "launch" && (value as { access?: unknown }).access !== undefined) throw new Error("Access can only be set on launch; resume keeps the member's access");
59
67
  }
60
68
  }
package/src/store.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
2
  import { dirname, join, resolve } from "node:path";
4
3
  import { randomUUID } from "node:crypto";
4
+ import { stateRoot } from "./config.ts";
5
+ import { validatePolicy } from "./egress.ts";
5
6
  import { stripVTControlCharacters } from "node:util";
6
- import { isDescendant, isLive, memberNamePattern, memberNameMaxLength, taskLabelMaxLength, taskLabelPattern } from "./types.ts";
7
+ import { isDescendant, isLive, isMain, memberNamePattern, memberNameMaxLength, taskLabelMaxLength, taskLabelPattern } from "./types.ts";
7
8
  import type { TeamState, TaskArgs, MessageArgs, Task, TaskView, Message, Worktree } from "./types.ts";
8
9
 
9
10
  // UI envelopes add task context after the public 16,000-character input limit.
@@ -53,7 +54,18 @@ function taskView(state: TeamState, task: Task): TaskView {
53
54
  }
54
55
 
55
56
  function migrate(state: unknown): boolean {
56
- if (!object(state) || (state.version !== 1 && state.version !== 2)) return false;
57
+ if (!object(state) || ![1, 2, 3].includes(state.version as number)) return false;
58
+ if (state.version !== 3) migrateTasks(state);
59
+ // Schema 4: roles replace the special root ID; the creator's record becomes a main session.
60
+ const root = state.root ?? "root";
61
+ requireValue(object(state.members) && typeof root === "string" && Object.hasOwn(state.members, root), "Invalid legacy root member");
62
+ for (const [key, member] of Object.entries(state.members)) if (object(member)) member.role = key === root ? "main" : "member";
63
+ delete state.root;
64
+ state.key = randomUUID();
65
+ state.version = 4;
66
+ return true;
67
+ }
68
+ function migrateTasks(state: Record<string, unknown>): void {
57
69
  requireValue(object(state.tasks), "Invalid legacy state tasks");
58
70
  let number = 0;
59
71
  for (const task of Object.values(state.tasks)) {
@@ -68,14 +80,12 @@ function migrate(state: unknown): boolean {
68
80
  task.number = ++number;
69
81
  task.label = stripVTControlCharacters(task.title).replace(/[\x00-\x1f\x7f-\x9f]/g, " ").trim().split(/\s+/).slice(0, 3).join(" ").slice(0, taskLabelMaxLength).trim() || "Task";
70
82
  }
71
- state.version = 3;
72
- return true;
73
83
  }
74
84
 
75
85
  function validate(state: unknown): asserts state is TeamState {
76
86
  requireValue(object(state), "Invalid state snapshot");
77
87
  id(state.id);
78
- requireValue(state.version === 3 && text(state.root) && integer(state.limit, 1) && integer(state.depth) && object(state.members) && object(state.tasks) && Array.isArray(state.messages), "Invalid state snapshot schema");
88
+ requireValue(state.version === 4 && text(state.key) && integer(state.limit, 1) && integer(state.depth) && object(state.members) && object(state.tasks) && Array.isArray(state.messages), "Invalid state snapshot schema");
79
89
  const members = state.members;
80
90
  const names = new Set<string>(["main", "root", ...Object.keys(members)]);
81
91
  for (const [key, member] of Object.entries(members)) {
@@ -85,15 +95,22 @@ function validate(state: unknown): asserts state is TeamState {
85
95
  names.add(member.name);
86
96
  }
87
97
  requireValue(member.parent === undefined || (text(member.parent) && Object.hasOwn(members, member.parent)), "Invalid member parent");
88
- for (const field of ["identity", "sessionId", "sessionFile", "error", "model", "modelName", "thinking", "activity", "summary"]) requireValue(member[field] === undefined || typeof member[field] === "string", "Invalid member field");
98
+ for (const field of ["identity", "sessionId", "sessionFile", "error", "model", "modelName", "thinking", "activity", "summary", "provider"]) requireValue(member[field] === undefined || typeof member[field] === "string", "Invalid member field");
89
99
  if (member.contextUsage !== undefined) {
90
100
  const usage = member.contextUsage;
91
101
  requireValue(object(usage) && integer(usage.contextWindow, 1) && (usage.tokens === null || integer(usage.tokens)) && (usage.percent === null || typeof usage.percent === "number" && Number.isFinite(usage.percent) && usage.percent >= 0), "Invalid member context usage");
92
102
  }
103
+ requireValue(member.role === undefined || member.role === "main" || member.role === "member", "Invalid member role");
104
+ requireValue(member.role !== "main" || member.parent === undefined, "A main session has no parent");
105
+ requireValue(member.handover === undefined || ["busy", "idle", "input"].includes(String(member.handover)), "Invalid member handover state");
106
+ requireValue(member.promotion === undefined || (object(member.promotion) && ["stopped", "moved", "role", "entry"].includes(String(member.promotion.step)) && text(member.promotion.sessionFile)), "Invalid member promotion");
93
107
  requireValue(member.terminal === undefined || typeof member.terminal === "boolean", "Invalid member terminal flag");
94
108
  requireValue(member.needsInput === undefined || typeof member.needsInput === "boolean", "Invalid member input flag");
95
109
  requireValue(member.pid === undefined || integer(member.pid, 1), "Invalid member PID");
96
110
  requireValue(member.worktree === undefined || validateWorktree(member.worktree), "Invalid member worktree");
111
+ const paths = (value: unknown) => Array.isArray(value) && value.every(path => typeof path === "string" && path.startsWith("/"));
112
+ const network = (value: unknown) => { try { return object(value) && (value.allow === undefined || (Array.isArray(value.allow) && value.allow.every(entry => typeof entry === "string"))) && (validatePolicy(value), true); } catch { return false; } };
113
+ requireValue(member.access === undefined || (object(member.access) && paths(member.access.read) && paths(member.access.write) && network(member.access.network)), "Invalid member access");
97
114
  const seen = new Set<string>([key]);
98
115
  for (let parent: unknown = member.parent; parent !== undefined;) {
99
116
  requireValue(typeof parent === "string" && !seen.has(parent), "Cyclic member ancestry");
@@ -101,7 +118,6 @@ function validate(state: unknown): asserts state is TeamState {
101
118
  parent = (members[parent] as Record<string, unknown>).parent;
102
119
  }
103
120
  }
104
- requireValue(Object.hasOwn(members, state.root), "Invalid root member");
105
121
  const tasks = state.tasks;
106
122
  const edges: Record<string, string[]> = {};
107
123
  const taskNumbers = new Set<number>();
@@ -152,13 +168,24 @@ function privateFile(path: string, flags: number): number {
152
168
  } catch (error) { closeSync(fd); throw error; }
153
169
  }
154
170
 
171
+ export const teamsHome = () => join(stateRoot(), "teams");
172
+ /** Reads a team snapshot without taking ownership, for sessions that connect to its coordinator as clients. */
173
+ export function readState(dir: string): TeamState {
174
+ const fd = privateFile(join(dir, "state.json"), constants.O_RDONLY);
175
+ try {
176
+ const stored: unknown = JSON.parse(readFileSync(fd, "utf8"));
177
+ migrate(stored); validate(stored);
178
+ return stored;
179
+ } finally { closeSync(fd); }
180
+ }
181
+
155
182
  export class Store {
156
183
  readonly dir: string;
157
184
  state: TeamState;
158
185
  private lock?: number;
159
186
  private closed = false;
160
187
 
161
- constructor(initial: TeamState, home = join(homedir(), ".acstack", "ac-teams", "teams")) {
188
+ constructor(initial: TeamState, home = teamsHome()) {
162
189
  validate(initial);
163
190
  this.dir = join(resolve(home), initial.id);
164
191
  this.state = structuredClone(initial);
@@ -295,12 +322,12 @@ export class Store {
295
322
  } else if (args.action === "release") {
296
323
  requireValue(task.owner, "Task has no owner to release");
297
324
  const interrupted = ["interrupted", "stopped"].includes(state.members[task.owner].status);
298
- requireValue(task.owner === actor || (interrupted && (actor === state.root || isDescendant(state, task.owner, actor))), "Only the owner can release this task; ancestors may release interrupted descendants");
325
+ requireValue(task.owner === actor || (interrupted && (isMain(state.members[actor]) || isDescendant(state, task.owner, actor))), "Only the owner can release this task; ancestors may release interrupted descendants");
299
326
  if (task.status === "in_progress") task.status = "todo";
300
327
  delete task.owner;
301
328
  } else if (args.action === "reopen") {
302
329
  requireValue(task.status === "done", "Only done tasks can be reopened");
303
- requireValue(!task.owner || task.owner === actor || actor === state.root, "Only the owner or root can reopen an owned task");
330
+ requireValue(!task.owner || task.owner === actor || isMain(state.members[actor]), "Only the owner or a main session can reopen an owned task");
304
331
  const dependents = Object.values(state.tasks).filter(other => other.dependencies.includes(task.id) && other.status !== "todo");
305
332
  requireValue(dependents.length === 0, `Cannot reopen task with active or done dependents: ${dependents.map(t => t.id).join(", ")}`);
306
333
  task.status = "todo";
@@ -44,7 +44,7 @@ export class TerminalAgent extends EventEmitter {
44
44
  writeFileSync(config, "set -g status off\nset -g prefix None\nset -s extended-keys always\nbind-key -n C-S-Left detach-client\n", { flag: "wx", mode: 0o600 });
45
45
  this.files.push(config);
46
46
  const exports = Object.entries(this.options.env)
47
- .filter(([key, value]) => (key.startsWith("AC_TEAMS_") || key === "HERDR_ENV") && value !== undefined)
47
+ .filter(([key, value]) => (key.startsWith("AC_TEAMS_") || key === "HERDR_ENV" || key === "ACSTACK_AGENT_ROLE") && value !== undefined)
48
48
  .map(([key, value]) => `export ${key}=${quote(value!)}`);
49
49
  const command = this.options.command ?? process.env.AC_TEAMS_PI ?? "pi";
50
50
  writeFileSync(script, [...exports,
@@ -14,10 +14,13 @@ export async function visitMember(ctx: ExtensionContext, host: PanelHost, socket
14
14
  if (ctx.mode !== "tui") throw new Error("Live sessions require an interactive terminal");
15
15
  if (id === host.self) return;
16
16
  const member = (await host.snapshot()).members.find(member => member.id === id);
17
+ // A member returns to whichever main session attached it by detaching.
18
+ if (host.child && member?.role === "main") { await exec("tmux", ["-S", socket, "detach-client", "-s", host.self], { timeout: 5000, maxBuffer: 65536 }); return; }
17
19
  if (!member || !isLive(member)) throw new Error(`Member ${id} is stopped or unavailable; resume it from /teams`);
18
- if (id !== "root" && !member.terminal) throw new Error("This member has no live terminal (it was launched headlessly)");
19
- if (host.self !== "root") {
20
- await exec("tmux", ["-S", socket, ...(id === "root" ? ["detach-client", "-s", host.self] : ["switch-client", "-t", id])], { timeout: 5000, maxBuffer: 65536 });
20
+ if (member.role === "main") throw new Error(`${memberName(member)} is a main session in its own terminal; switch to it there`);
21
+ if (!member.terminal) throw new Error("This member has no live terminal (it was launched headlessly)");
22
+ if (host.child) {
23
+ await exec("tmux", ["-S", socket, "switch-client", "-t", id], { timeout: 5000, maxBuffer: 65536 });
21
24
  return;
22
25
  }
23
26
  const code = await ctx.ui.custom<number>((tui, _theme, _keys, done) => {
package/src/types.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Access, AccessRequest } from "./access.ts";
1
2
  export interface Worktree {
2
3
  repo: string;
3
4
  path: string;
@@ -39,10 +40,25 @@ export interface Member {
39
40
  summary?: string;
40
41
  terminal?: boolean;
41
42
  needsInput?: boolean;
43
+ access?: Access;
44
+ /** Provider fixed at an enforced member's first launch; member telemetry cannot change it. */
45
+ provider?: string;
46
+ /** Authority: a main session or a member. Absent once a main session has left the team. */
47
+ role?: "main" | "member";
48
+ /** State recorded when the coordinator owner stopped this member; the next owner's resume pass consumes it. */
49
+ handover?: "busy" | "idle" | "input";
50
+ /** Promotion in progress: the last completed step and what a rollback restores. */
51
+ promotion?: { step: "stopped" | "moved" | "role" | "entry"; sessionFile: string; parent?: string; access?: Access; provider?: string };
42
52
  }
43
53
  export const memberNameMaxLength = 40;
44
54
  export const memberNamePattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)?(?![\s\S])/;
45
55
  export function memberName(member: Pick<Member, "id" | "name">): string { return member.id === "root" ? "Main" : member.name ?? member.id; }
56
+ export function isMain(member: Pick<Member, "role"> | undefined): boolean { return member?.role === "main"; }
57
+ /** Mains control every member and other mains; members control only their descendants, never a main. */
58
+ export function canControl(state: TeamState, actor: string, id: string): boolean {
59
+ const target = state.members[id];
60
+ return !!target && id !== actor && (isMain(state.members[actor]) ? target.role !== undefined : !isMain(target) && isDescendant(state, id, actor));
61
+ }
46
62
  export const taskLabelMaxLength = 60;
47
63
  export const taskLabelPattern = /^[^\s\x00-\x1f\x7f-\x9f]+(?: [^\s\x00-\x1f\x7f-\x9f]+){0,2}(?![\s\S])/;
48
64
  export function taskName(task: Pick<Task, "number" | "label">): string { return `#${task.number} ${task.label}`; }
@@ -71,9 +87,10 @@ export interface Message {
71
87
  time: string;
72
88
  }
73
89
  export interface TeamState {
74
- version: 3;
90
+ version: 4;
75
91
  id: string;
76
- root: string;
92
+ /** Lets an unsandboxed session of the same user add itself as a main session. */
93
+ key: string;
77
94
  limit: number;
78
95
  depth: number;
79
96
  members: Record<string, Member>;
@@ -113,15 +130,18 @@ export interface AgentArgs {
113
130
  target?: string;
114
131
  limit?: number;
115
132
  depth?: number;
133
+ access?: AccessRequest;
116
134
  }
117
135
  export interface UserAction {
118
- action: "task_request" | "feedback" | "resume";
136
+ action: "task_request" | "feedback" | "resume" | "promote" | "leave";
119
137
  id?: string;
138
+ /** Promote: the user approved running a sandboxed member with full access. */
139
+ confirmed?: boolean;
120
140
  text?: string;
121
141
  model?: string;
122
142
  thinking?: string;
123
143
  }
124
- export type Operation = "agent" | "task" | "message" | "join" | "ack" | "event" | "control_result" | "ui";
144
+ export type Operation = "agent" | "task" | "message" | "join" | "ack" | "event" | "control_result" | "ui" | "add_main";
125
145
  export function isLive(member: Pick<Member, "status">): boolean {
126
146
  return ["starting", "busy", "idle", "stopping"].includes(member.status);
127
147
  }
package/src/ui-types.ts CHANGED
@@ -10,10 +10,14 @@ export interface TeamSnapshot {
10
10
 
11
11
  export interface PanelHost {
12
12
  self: string;
13
+ /** True in a member session; false in a main session. */
14
+ child: boolean;
13
15
  snapshot(): Promise<TeamSnapshot>;
14
16
  visit(id: string): Promise<void>;
15
17
  requestTask(text: string): Promise<string>;
16
18
  feedback(task: TaskView, text: string): Promise<string>;
17
19
  resume(memberId: string): Promise<void>;
20
+ /** Stops a member and relaunches it as a main session (user only). */
21
+ promote(memberId: string): Promise<void>;
18
22
  drafts: Map<string, string>;
19
23
  }