ac-teams 0.0.7 → 0.0.9

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/store.ts CHANGED
@@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
4
4
  import { stateRoot } from "./config.ts";
5
5
  import { validatePolicy } from "./egress.ts";
6
6
  import { stripVTControlCharacters } from "node:util";
7
- import { isDescendant, isLive, memberNamePattern, memberNameMaxLength, taskLabelMaxLength, taskLabelPattern } from "./types.ts";
7
+ import { isDescendant, isLive, isMain, memberNamePattern, memberNameMaxLength, taskLabelMaxLength, taskLabelPattern } from "./types.ts";
8
8
  import type { TeamState, TaskArgs, MessageArgs, Task, TaskView, Message, Worktree } from "./types.ts";
9
9
 
10
10
  // UI envelopes add task context after the public 16,000-character input limit.
@@ -54,7 +54,18 @@ function taskView(state: TeamState, task: Task): TaskView {
54
54
  }
55
55
 
56
56
  function migrate(state: unknown): boolean {
57
- 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 {
58
69
  requireValue(object(state.tasks), "Invalid legacy state tasks");
59
70
  let number = 0;
60
71
  for (const task of Object.values(state.tasks)) {
@@ -69,14 +80,12 @@ function migrate(state: unknown): boolean {
69
80
  task.number = ++number;
70
81
  task.label = stripVTControlCharacters(task.title).replace(/[\x00-\x1f\x7f-\x9f]/g, " ").trim().split(/\s+/).slice(0, 3).join(" ").slice(0, taskLabelMaxLength).trim() || "Task";
71
82
  }
72
- state.version = 3;
73
- return true;
74
83
  }
75
84
 
76
85
  function validate(state: unknown): asserts state is TeamState {
77
86
  requireValue(object(state), "Invalid state snapshot");
78
87
  id(state.id);
79
- 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");
80
89
  const members = state.members;
81
90
  const names = new Set<string>(["main", "root", ...Object.keys(members)]);
82
91
  for (const [key, member] of Object.entries(members)) {
@@ -91,6 +100,10 @@ function validate(state: unknown): asserts state is TeamState {
91
100
  const usage = member.contextUsage;
92
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");
93
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");
94
107
  requireValue(member.terminal === undefined || typeof member.terminal === "boolean", "Invalid member terminal flag");
95
108
  requireValue(member.needsInput === undefined || typeof member.needsInput === "boolean", "Invalid member input flag");
96
109
  requireValue(member.pid === undefined || integer(member.pid, 1), "Invalid member PID");
@@ -105,7 +118,6 @@ function validate(state: unknown): asserts state is TeamState {
105
118
  parent = (members[parent] as Record<string, unknown>).parent;
106
119
  }
107
120
  }
108
- requireValue(Object.hasOwn(members, state.root), "Invalid root member");
109
121
  const tasks = state.tasks;
110
122
  const edges: Record<string, string[]> = {};
111
123
  const taskNumbers = new Set<number>();
@@ -156,13 +168,24 @@ function privateFile(path: string, flags: number): number {
156
168
  } catch (error) { closeSync(fd); throw error; }
157
169
  }
158
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
+
159
182
  export class Store {
160
183
  readonly dir: string;
161
184
  state: TeamState;
162
185
  private lock?: number;
163
186
  private closed = false;
164
187
 
165
- constructor(initial: TeamState, home = join(stateRoot(), "teams")) {
188
+ constructor(initial: TeamState, home = teamsHome()) {
166
189
  validate(initial);
167
190
  this.dir = join(resolve(home), initial.id);
168
191
  this.state = structuredClone(initial);
@@ -299,12 +322,12 @@ export class Store {
299
322
  } else if (args.action === "release") {
300
323
  requireValue(task.owner, "Task has no owner to release");
301
324
  const interrupted = ["interrupted", "stopped"].includes(state.members[task.owner].status);
302
- 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");
303
326
  if (task.status === "in_progress") task.status = "todo";
304
327
  delete task.owner;
305
328
  } else if (args.action === "reopen") {
306
329
  requireValue(task.status === "done", "Only done tasks can be reopened");
307
- 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");
308
331
  const dependents = Object.values(state.tasks).filter(other => other.dependencies.includes(task.id) && other.status !== "todo");
309
332
  requireValue(dependents.length === 0, `Cannot reopen task with active or done dependents: ${dependents.map(t => t.id).join(", ")}`);
310
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
@@ -43,10 +43,22 @@ export interface Member {
43
43
  access?: Access;
44
44
  /** Provider fixed at an enforced member's first launch; member telemetry cannot change it. */
45
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 };
46
52
  }
47
53
  export const memberNameMaxLength = 40;
48
54
  export const memberNamePattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)?(?![\s\S])/;
49
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
+ }
50
62
  export const taskLabelMaxLength = 60;
51
63
  export const taskLabelPattern = /^[^\s\x00-\x1f\x7f-\x9f]+(?: [^\s\x00-\x1f\x7f-\x9f]+){0,2}(?![\s\S])/;
52
64
  export function taskName(task: Pick<Task, "number" | "label">): string { return `#${task.number} ${task.label}`; }
@@ -75,9 +87,10 @@ export interface Message {
75
87
  time: string;
76
88
  }
77
89
  export interface TeamState {
78
- version: 3;
90
+ version: 4;
79
91
  id: string;
80
- root: string;
92
+ /** Lets an unsandboxed session of the same user add itself as a main session. */
93
+ key: string;
81
94
  limit: number;
82
95
  depth: number;
83
96
  members: Record<string, Member>;
@@ -120,13 +133,15 @@ export interface AgentArgs {
120
133
  access?: AccessRequest;
121
134
  }
122
135
  export interface UserAction {
123
- action: "task_request" | "feedback" | "resume";
136
+ action: "task_request" | "feedback" | "resume" | "promote" | "leave";
124
137
  id?: string;
138
+ /** Promote: the user approved running a sandboxed member with full access. */
139
+ confirmed?: boolean;
125
140
  text?: string;
126
141
  model?: string;
127
142
  thinking?: string;
128
143
  }
129
- 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";
130
145
  export function isLive(member: Pick<Member, "status">): boolean {
131
146
  return ["starting", "busy", "idle", "stopping"].includes(member.status);
132
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
  }