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/README.md +4 -3
- package/docs/access.md +103 -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/development.md +18 -0
- package/docs/operations.md +18 -7
- package/docs/usage.md +14 -3
- package/package.json +1 -1
- package/src/access.ts +175 -0
- package/src/commands.ts +1 -1
- package/src/config.ts +4 -2
- package/src/coordinator.ts +472 -38
- package/src/egress.ts +187 -0
- package/src/index.ts +253 -58
- package/src/mains.ts +41 -0
- package/src/panel.ts +9 -1
- package/src/sandbox-bridge.mjs +33 -0
- package/src/sandbox.Dockerfile +8 -0
- package/src/schemas.ts +10 -2
- package/src/store.ts +38 -11
- package/src/terminal-agent.ts +1 -1
- package/src/terminal-ui.ts +6 -3
- package/src/types.ts +24 -4
- package/src/ui-types.ts +4 -0
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
|
-
|
|
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) ||
|
|
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 ===
|
|
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 =
|
|
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 && (
|
|
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 ||
|
|
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";
|
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
|
@@ -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:
|
|
90
|
+
version: 4;
|
|
75
91
|
id: string;
|
|
76
|
-
|
|
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
|
}
|