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/README.md +1 -1
- package/docs/access.md +4 -0
- package/docs/design/session-roles/README.md +343 -0
- package/docs/design/session-roles/coordinator.yaml +44 -0
- package/docs/design/session-roles/design.yaml +70 -0
- package/docs/design/session-roles/herdr.yaml +17 -0
- package/docs/design/session-roles/journey.md +139 -0
- package/docs/design/session-roles/main-session.yaml +34 -0
- package/docs/design/session-roles/member.yaml +28 -0
- package/docs/design/session-roles/read-guard.yaml +17 -0
- package/docs/design/session-roles/team-store.yaml +13 -0
- package/docs/design/session-roles/transcripts.yaml +23 -0
- package/docs/design/session-roles/user.yaml +17 -0
- package/docs/operations.md +13 -2
- package/docs/usage.md +31 -9
- package/package.json +1 -1
- package/src/commands.ts +1 -1
- package/src/coordinator.ts +242 -31
- package/src/index.ts +243 -61
- package/src/mains.ts +41 -0
- package/src/panel.ts +9 -1
- package/src/schemas.ts +2 -2
- package/src/store.ts +32 -9
- package/src/terminal-agent.ts +1 -1
- package/src/terminal-ui.ts +6 -3
- package/src/types.ts +19 -4
- package/src/ui-types.ts +4 -0
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) ||
|
|
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 ===
|
|
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 =
|
|
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 && (
|
|
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 ||
|
|
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";
|
package/src/terminal-agent.ts
CHANGED
|
@@ -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,
|
package/src/terminal-ui.ts
CHANGED
|
@@ -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 (
|
|
19
|
-
if (
|
|
20
|
-
|
|
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:
|
|
90
|
+
version: 4;
|
|
79
91
|
id: string;
|
|
80
|
-
|
|
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
|
}
|