ac-teams 0.0.7 → 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 +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 +13 -2
- package/package.json +1 -1
- package/src/commands.ts +1 -1
- package/src/coordinator.ts +241 -30
- package/src/index.ts +238 -57
- 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/index.ts
CHANGED
|
@@ -9,6 +9,13 @@ import { resolveTeammate, teamsCompletions } from "./commands.ts";
|
|
|
9
9
|
import { showTeamsPanel } from "./panel.ts";
|
|
10
10
|
import { teamToolRenderers, renderTeamMessage, cleanText } from "./render.ts";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import { execFile } from "node:child_process";
|
|
14
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
15
|
+
import { promisify } from "node:util";
|
|
16
|
+
import { readState, teamsHome } from "./store.ts";
|
|
17
|
+
import { teamEntryType, type TeamEntry } from "./mains.ts";
|
|
18
|
+
import { isMain, memberName, taskName, type TeamState } from "./types.ts";
|
|
12
19
|
import { Coordinator, assistantEvent, taskRequestGuidance } from "./coordinator.ts";
|
|
13
20
|
import { Connection, isRecord } from "./connection.ts";
|
|
14
21
|
import { policy, readDisplaySettings, defaultDisplaySettings } from "./config.ts";
|
|
@@ -30,21 +37,113 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
30
37
|
let disposeUI: (() => void) | undefined;
|
|
31
38
|
let telemetryTimer: NodeJS.Timeout | undefined;
|
|
32
39
|
let inputCount = 0;
|
|
40
|
+
/** This main session's team, from its transcript's team entry; a session with no entry leads its own team as "root". */
|
|
41
|
+
let team: TeamEntry | undefined;
|
|
42
|
+
/** Why team tools are off in this session, if they are. */
|
|
43
|
+
let disabled: string | undefined;
|
|
44
|
+
let linking: Promise<void> | undefined;
|
|
45
|
+
let linkedSession: string | undefined;
|
|
33
46
|
const drafts = new Map<string, string>();
|
|
34
47
|
const display = { ...defaultDisplaySettings };
|
|
35
|
-
const
|
|
48
|
+
const teamTools = ["team_agent", "team_task", "team_message"];
|
|
49
|
+
const memberId = () => child ? environment[1]! : team?.member ?? "root";
|
|
50
|
+
const teamId = (ctx: ExtensionContext) => team?.team ?? ctx.sessionManager.getSessionId();
|
|
36
51
|
pi.registerMessageRenderer("ac-teams", renderTeamMessage);
|
|
37
52
|
const warn = (message: string) => context?.ui.notify(message, "warning");
|
|
38
53
|
const notify = (through: number, messages: Message[] = []) => {
|
|
39
54
|
pi.sendMessage({ customType: "ac-teams", content: `New team messages through sequence ${through}. Read team_message history after your last cursor. Reply only with useful updates, not acknowledgements.`, display: true, details: { through, messages } }, { deliverAs: "followUp", triggerTurn: true });
|
|
40
55
|
if (connection) void connection.request("ack", { through }).catch(error => warn(String(error)));
|
|
41
|
-
else coordinator?.ack(
|
|
56
|
+
else coordinator?.ack(coordinator.self, through);
|
|
42
57
|
};
|
|
43
|
-
|
|
58
|
+
function disable(reason: string) {
|
|
59
|
+
disabled = reason;
|
|
60
|
+
pi.setActiveTools(pi.getActiveTools().filter(name => !teamTools.includes(name)));
|
|
61
|
+
}
|
|
62
|
+
function enable() {
|
|
63
|
+
disabled = undefined;
|
|
64
|
+
pi.setActiveTools([...new Set([...pi.getActiveTools(), ...teamTools])]);
|
|
65
|
+
}
|
|
66
|
+
function remember(entry: TeamEntry) {
|
|
67
|
+
team = entry;
|
|
68
|
+
pi.appendEntry(teamEntryType, entry);
|
|
69
|
+
}
|
|
70
|
+
/** Runs a native command sent by the coordinator: members get these from their parent, main sessions from other mains. */
|
|
71
|
+
function onCommand(ctx: ExtensionContext, link: Connection) {
|
|
72
|
+
return (command: { id: string; command: string; args: Record<string, unknown> }) => {
|
|
73
|
+
void (async () => {
|
|
74
|
+
let data: unknown = {}, error: string | undefined;
|
|
75
|
+
try {
|
|
76
|
+
if (command.command === "get_state") data = { sessionId: ctx.sessionManager.getSessionId(), sessionFile: ctx.sessionManager.getSessionFile(), model: ctx.model ? { id: ctx.model.id, provider: ctx.model.provider, name: ctx.model.name } : undefined, contextUsage: readTelemetry(ctx).contextUsage, isStreaming: !ctx.isIdle() };
|
|
77
|
+
else if (command.command === "prompt") {
|
|
78
|
+
if (typeof command.args.message !== "string" || !command.args.message.trim() || command.args.message.length > 16000) throw new Error("Invalid native prompt");
|
|
79
|
+
pi.sendUserMessage(command.args.message, { deliverAs: "followUp" });
|
|
80
|
+
} else if (command.command === "abort") ctx.abort();
|
|
81
|
+
else if (command.command !== "shutdown") throw new Error("Unsupported native command");
|
|
82
|
+
} catch (failure) { error = String(failure).slice(0, 2000); }
|
|
83
|
+
try { await link.request("control_result", { id: command.id, data, error }); }
|
|
84
|
+
catch { return; }
|
|
85
|
+
if (command.command === "shutdown" && !error) { exiting = true; ctx.shutdown(); }
|
|
86
|
+
})();
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** Connects to a running coordinator as a main-session client; false when none is listening. */
|
|
90
|
+
async function connectMain(ctx: ExtensionContext): Promise<boolean> {
|
|
91
|
+
const dir = join(teamsHome(), teamId(ctx)), socket = join(dir, "coordinator.sock");
|
|
92
|
+
if (!existsSync(socket)) return false;
|
|
93
|
+
const record = readState(dir).members[memberId()];
|
|
94
|
+
if (!isMain(record)) throw new Error("This session is no longer a main session of its team; use /teams join");
|
|
95
|
+
const link = new Connection(socket, record.id, record.token);
|
|
96
|
+
try { await link.open(); } catch { return false; }
|
|
97
|
+
link.on("notice", (through, messages) => notify(through, messages));
|
|
98
|
+
link.on("command", onCommand(ctx, link));
|
|
99
|
+
link.on("disconnect", () => {
|
|
100
|
+
if (connection !== link) return;
|
|
101
|
+
connection = undefined;
|
|
102
|
+
if (!exiting) void takeover(ctx);
|
|
103
|
+
});
|
|
104
|
+
try { await link.request("join", { sessionId: ctx.sessionManager.getSessionId(), sessionFile: ctx.sessionManager.getSessionFile(), tools: pi.getAllTools().map(tool => tool.name), trusted: ctx.isProjectTrusted() }); }
|
|
105
|
+
catch (error) { link.close(); throw error; }
|
|
106
|
+
connection = link; linkedSession = ctx.sessionManager.getSessionId();
|
|
107
|
+
if (!team) remember({ team: teamId(ctx), member: record.id });
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
/** The coordinator owner went away: one main session takes the owner lock, the rest reconnect to it. */
|
|
111
|
+
async function takeover(ctx: ExtensionContext) {
|
|
112
|
+
warn("The team coordinator stopped; taking over or reconnecting");
|
|
113
|
+
for (let attempt = 0; attempt < 40 && !exiting; attempt++) {
|
|
114
|
+
await new Promise(resolve => setTimeout(resolve, 250 + Math.random() * 250));
|
|
115
|
+
try {
|
|
116
|
+
await link(ctx);
|
|
117
|
+
warn(coordinator ? "This session now runs the team coordinator" : "Reconnected to the team coordinator");
|
|
118
|
+
return;
|
|
119
|
+
} catch { /* The new owner may still be starting. */ }
|
|
120
|
+
}
|
|
121
|
+
if (!exiting) warn("Could not reach or take over the team coordinator; team tools will retry on next use");
|
|
122
|
+
}
|
|
123
|
+
/** Makes this session an owner or client of its team's coordinator. */
|
|
124
|
+
async function link(ctx: ExtensionContext): Promise<void> {
|
|
125
|
+
if (child) { if (!connection) throw new Error("Team coordinator is not connected"); return; }
|
|
126
|
+
if (exiting) throw new Error("Team is stopping");
|
|
127
|
+
if (disabled) throw new Error(disabled);
|
|
128
|
+
if (connection || coordinator) return;
|
|
129
|
+
linking ??= (async () => {
|
|
130
|
+
if (await connectMain(ctx)) return;
|
|
131
|
+
try { await root(ctx); }
|
|
132
|
+
catch (error) {
|
|
133
|
+
// Another main session won the owner lock first; join it as a client.
|
|
134
|
+
if (String(error).includes("live owner lock") && await connectMain(ctx)) return;
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
})().finally(() => { linking = undefined; });
|
|
138
|
+
return linking;
|
|
139
|
+
}
|
|
140
|
+
async function root(ctx: ExtensionContext, join?: { id: string; member: string }): Promise<Coordinator> {
|
|
44
141
|
if (exiting) throw new Error("Team is stopping");
|
|
45
142
|
context = ctx;
|
|
46
143
|
opening ??= Coordinator.open({
|
|
47
|
-
id: ctx
|
|
144
|
+
id: join?.id ?? teamId(ctx), self: join?.member ?? memberId(), join: join !== undefined, sessionId: ctx.sessionManager.getSessionId(),
|
|
145
|
+
shutdown: () => { exiting = true; ctx.shutdown(); },
|
|
146
|
+
cwd: ctx.cwd, sessionFile: ctx.sessionManager.getSessionFile(), sessionDir: ctx.sessionManager.getSessionDir(),
|
|
48
147
|
...policy(ctx.cwd, pi.getFlag("teams-project-config") === true, { limit: pi.getFlag("teams-limit") as string | undefined, depth: pi.getFlag("teams-depth") as string | undefined }),
|
|
49
148
|
extension: fileURLToPath(import.meta.url), notice: notify, warning: warn, piVersion: VERSION,
|
|
50
149
|
status: (kind, active) => pi.events.emit(`herdr:${kind}`, { active, source: "ac-teams" }),
|
|
@@ -62,9 +161,10 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
62
161
|
},
|
|
63
162
|
interactive: ctx.mode === "tui", model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined, thinking: pi.getThinkingLevel(),
|
|
64
163
|
}).then(value => {
|
|
65
|
-
coordinator = value;
|
|
66
|
-
if (
|
|
67
|
-
if (
|
|
164
|
+
coordinator = value; linkedSession = ctx.sessionManager.getSessionId();
|
|
165
|
+
if (!team) remember({ team: value.store.state.id, member: value.self });
|
|
166
|
+
if (ctx.isIdle?.() === false) value.recordEvent(value.self, { type: "agent_start" });
|
|
167
|
+
if (inputCount > 0) value.recordEvent(value.self, { type: "blocked", active: true });
|
|
68
168
|
return value;
|
|
69
169
|
}, error => { opening = undefined; throw error; });
|
|
70
170
|
return opening;
|
|
@@ -80,19 +180,18 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
80
180
|
}
|
|
81
181
|
args = agent;
|
|
82
182
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
183
|
+
await link(ctx);
|
|
184
|
+
// shortcut: client main sessions cannot approve a ceiling above 10, only the owner can; add a confirmation relay if needed.
|
|
185
|
+
if (connection) return connection.request(operation, args);
|
|
87
186
|
let authorized = false;
|
|
88
187
|
if (operation === "agent" && (args as AgentArgs).action === "limits" && ((args as AgentArgs).limit ?? 0) > 10) {
|
|
89
188
|
if (!ctx.hasUI || !await ctx.ui.confirm("Raise team ceiling?", `Allow up to ${(args as AgentArgs).limit} live Pi members, including idle members? This increases possible model costs.`)) throw new Error("Ceiling increase was not explicitly approved");
|
|
90
189
|
authorized = true;
|
|
91
190
|
}
|
|
92
|
-
return
|
|
191
|
+
return coordinator!.handle(coordinator!.self, operation, args, authorized);
|
|
93
192
|
}
|
|
94
193
|
async function snapshot(ctx: ExtensionContext): Promise<TeamSnapshot> {
|
|
95
|
-
if (!child && !opening) return { members: [{ id: "root", cwd: ctx.cwd, depth: 0, status: ctx.isIdle() ? "idle" : "busy", notified: 0, needsInput: inputCount > 0 }], tasks: [], messages: [], limit: 10, depth: 2 };
|
|
194
|
+
if (!child && !opening && !connection) return { members: [{ id: "root", cwd: ctx.cwd, depth: 0, status: ctx.isIdle() ? "idle" : "busy", notified: 0, needsInput: inputCount > 0 }], tasks: [], messages: [], limit: 10, depth: 2 };
|
|
96
195
|
const status = await handle("agent", { action: "list" }, ctx) as Pick<TeamSnapshot, "members" | "limit" | "depth"> & { lastMessage: number };
|
|
97
196
|
const tasks: TaskView[] = [];
|
|
98
197
|
let total = 0;
|
|
@@ -107,30 +206,35 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
107
206
|
function host(ctx: ExtensionContext): PanelHost {
|
|
108
207
|
const userAction = async (args: UserAction): Promise<string> => {
|
|
109
208
|
const value = { ...args, model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined, thinking: pi.getThinkingLevel() };
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
return await (await root(ctx)).handle("root", "ui", value) as string;
|
|
209
|
+
await link(ctx);
|
|
210
|
+
if (connection) return await connection.request("ui", value) as string;
|
|
211
|
+
return await coordinator!.handle(coordinator!.self, "ui", value) as string;
|
|
115
212
|
};
|
|
116
213
|
const api: PanelHost = {
|
|
117
|
-
self, drafts, snapshot: () => snapshot(ctx),
|
|
214
|
+
self: memberId(), child, drafts, snapshot: () => snapshot(ctx),
|
|
118
215
|
visit: async id => {
|
|
119
|
-
if (id ===
|
|
120
|
-
|
|
216
|
+
if (id === memberId()) return;
|
|
217
|
+
if (!child) await link(ctx);
|
|
218
|
+
const socket = child ? process.env.AC_TEAMS_TERMINAL_SOCKET : join(teamsHome(), teamId(ctx), "t.sock");
|
|
121
219
|
if (!socket) throw new Error("This member has no live terminal transport");
|
|
122
220
|
await visitMember(ctx, api, socket, id);
|
|
123
221
|
},
|
|
124
222
|
requestTask: text => userAction({ action: "task_request", text }),
|
|
125
223
|
feedback: (task, text) => userAction({ action: "feedback", id: task.id, text }),
|
|
126
224
|
resume: async id => { await userAction({ action: "resume", id }); },
|
|
225
|
+
promote: async id => {
|
|
226
|
+
const member = (await snapshot(ctx)).members.find(member => member.id === id);
|
|
227
|
+
const confirmed = member?.access ? await ctx.ui.confirm("Promote a sandboxed member?", `${memberName(member)} will leave its sandbox and run as a main session with your full access.`) : false;
|
|
228
|
+
if (member?.access && !confirmed) return;
|
|
229
|
+
ctx.ui.notify(await userAction({ action: "promote", id, confirmed }), "info");
|
|
230
|
+
},
|
|
127
231
|
};
|
|
128
232
|
return api;
|
|
129
233
|
}
|
|
130
234
|
function report(ctx: ExtensionContext, event: { type: string; text?: string; active?: boolean }) {
|
|
131
235
|
if (exiting) return;
|
|
132
|
-
if (
|
|
133
|
-
else
|
|
236
|
+
if (connection && (!child || ctx.mode === "tui")) void connection.request("event", event).catch(error => warn(cleanText(String(error))));
|
|
237
|
+
else coordinator?.recordEvent(coordinator.self, event);
|
|
134
238
|
}
|
|
135
239
|
function startTelemetry(ctx: ExtensionContext) {
|
|
136
240
|
clearInterval(telemetryTimer);
|
|
@@ -141,8 +245,8 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
141
245
|
try {
|
|
142
246
|
const event = readTelemetry(ctx), encoded = JSON.stringify(event);
|
|
143
247
|
if (encoded === previous) return;
|
|
144
|
-
if (
|
|
145
|
-
else coordinator?.recordEvent(
|
|
248
|
+
if (connection) await connection.request("event", event);
|
|
249
|
+
else coordinator?.recordEvent(coordinator.self, event);
|
|
146
250
|
previous = encoded;
|
|
147
251
|
} catch (error) { if (!exiting) warn(cleanText(String(error))); }
|
|
148
252
|
finally { pending = false; }
|
|
@@ -157,8 +261,8 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
157
261
|
inputCount = Math.max(0, inputCount + (data.active ? 1 : -1));
|
|
158
262
|
if (context) {
|
|
159
263
|
const event = { type: "blocked", active: inputCount > 0 };
|
|
160
|
-
if (
|
|
161
|
-
else coordinator?.recordEvent(
|
|
264
|
+
if (connection) void connection.request("event", event).catch(error => warn(cleanText(String(error))));
|
|
265
|
+
else coordinator?.recordEvent(coordinator.self, event);
|
|
162
266
|
}
|
|
163
267
|
});
|
|
164
268
|
pi.on("agent_start", (_event, ctx) => report(ctx, { type: "agent_start" }));
|
|
@@ -196,34 +300,104 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
196
300
|
disposeUI?.(); disposeUI = undefined;
|
|
197
301
|
connection?.close(); connection = undefined;
|
|
198
302
|
if (opening) await (await opening).close();
|
|
199
|
-
coordinator = undefined; opening = undefined;
|
|
303
|
+
coordinator = undefined; opening = undefined; linkedSession = undefined;
|
|
304
|
+
}
|
|
305
|
+
/** A resumed main session reconnects to its team, or offers to run the coordinator and resume the team's members. */
|
|
306
|
+
async function rejoin(ctx: ExtensionContext) {
|
|
307
|
+
try {
|
|
308
|
+
if (await connectMain(ctx)) return;
|
|
309
|
+
const dir = join(teamsHome(), teamId(ctx));
|
|
310
|
+
if (!existsSync(join(dir, "state.json"))) return;
|
|
311
|
+
const state = readState(dir);
|
|
312
|
+
if (!isMain(state.members[memberId()])) { disable("This session is no longer a main session of its team; use /teams join"); return; }
|
|
313
|
+
const others = Object.values(state.members).filter(member => member.id !== memberId() && member.role);
|
|
314
|
+
if (!ctx.hasUI || !others.length) return;
|
|
315
|
+
if (await ctx.ui.confirm("Run the team coordinator?", `This is a main session of a team with ${others.length} other member(s), and no coordinator is running. Run it here and resume the members that were working?`)) await link(ctx);
|
|
316
|
+
else disable("You declined to run the team coordinator; team tools are off in this session. Resume the session again to take over.");
|
|
317
|
+
} catch (error) { warn(cleanText(String(error))); }
|
|
318
|
+
}
|
|
319
|
+
async function repository(cwd: string): Promise<string> {
|
|
320
|
+
try { return (await promisify(execFile)("git", ["-C", cwd, "rev-parse", "--path-format=absolute", "--git-common-dir"], { timeout: 5000 })).stdout.trim(); }
|
|
321
|
+
catch { return cwd; }
|
|
322
|
+
}
|
|
323
|
+
/** /teams join: adds this session to another team in the same project as a main session. */
|
|
324
|
+
async function joinTeam(ctx: ExtensionContext) {
|
|
325
|
+
if (child) throw new Error("A member cannot join another team");
|
|
326
|
+
if (!ctx.hasUI) throw new Error("/teams join requires an interactive session");
|
|
327
|
+
const sessionId = ctx.sessionManager.getSessionId(), home = teamsHome();
|
|
328
|
+
const current = existsSync(join(home, teamId(ctx), "state.json")) ? readState(join(home, teamId(ctx))) : undefined;
|
|
329
|
+
if (current && Object.values(current.members).some(member => member.id !== memberId() && member.role)) throw new Error("This session is already in a team with other members; use /teams leave first");
|
|
330
|
+
const repo = await repository(ctx.cwd), teams: TeamState[] = [];
|
|
331
|
+
for (const name of existsSync(home) ? readdirSync(home) : []) {
|
|
332
|
+
try {
|
|
333
|
+
const state = readState(join(home, name)), main = Object.values(state.members).find(isMain);
|
|
334
|
+
if (state.id !== current?.id && main && await repository(main.cwd) === repo) teams.push(state);
|
|
335
|
+
} catch { /* Unreadable or foreign team folders are not offered. */ }
|
|
336
|
+
}
|
|
337
|
+
if (!teams.length) { ctx.ui.notify("No other teams in this project", "info"); return; }
|
|
338
|
+
const describe = (state: TeamState) => {
|
|
339
|
+
const members = Object.values(state.members);
|
|
340
|
+
return `${members.filter(isMain).map(memberName).join(", ")} · ${members.filter(member => member.role === "member").length} member(s) · ${Object.values(state.tasks).filter(task => task.status !== "done").length} open task(s) · ${state.id.slice(0, 8)}`;
|
|
341
|
+
};
|
|
342
|
+
const labels = teams.map(describe), choice = await ctx.ui.select("Join which team?", labels);
|
|
343
|
+
const chosen = teams[labels.indexOf(choice ?? "")];
|
|
344
|
+
if (!chosen) return;
|
|
345
|
+
if (Object.values(chosen.members).some(member => member.sessionId === sessionId)) throw new Error("This session is already in that team");
|
|
346
|
+
await close(); exiting = false; enable();
|
|
347
|
+
const dir = join(home, chosen.id), probe = new Connection(join(dir, "coordinator.sock"), "join", chosen.key);
|
|
348
|
+
let running = false;
|
|
349
|
+
try { await probe.open(); running = true; } catch { /* No coordinator: this session runs it. */ }
|
|
350
|
+
if (running) {
|
|
351
|
+
try {
|
|
352
|
+
const added = await probe.request("add_main", { sessionId, sessionFile: ctx.sessionManager.getSessionFile(), cwd: ctx.cwd }) as { id: string };
|
|
353
|
+
remember({ team: chosen.id, member: added.id });
|
|
354
|
+
} finally { probe.close(); }
|
|
355
|
+
if (!await connectMain(ctx)) throw new Error("The team coordinator stopped while joining; resume this session to retry");
|
|
356
|
+
} else {
|
|
357
|
+
const member = randomUUID().slice(0, 16);
|
|
358
|
+
await root(ctx, { id: chosen.id, member });
|
|
359
|
+
remember({ team: chosen.id, member });
|
|
360
|
+
}
|
|
361
|
+
const state = readState(dir), name = (id?: string) => id ? memberName(state.members[id] ?? { id }) : "unowned";
|
|
362
|
+
const open = Object.values(state.tasks).filter(task => task.status !== "done");
|
|
363
|
+
const recent = state.messages.slice(-5);
|
|
364
|
+
pi.sendMessage({ customType: "ac-teams", display: true, details: { through: state.messages.at(-1)?.seq ?? 0, messages: recent }, content: [
|
|
365
|
+
`Joined team ${state.id} as ${name(team!.member)}, a main session.`,
|
|
366
|
+
open.length ? `Open tasks:\n${open.map(task => `- ${taskName(task)} (${task.status}, ${name(task.owner)})`).join("\n")}` : "No open tasks.",
|
|
367
|
+
recent.length ? `Recent messages:\n${recent.map(message => `- ${name(message.from)}: ${message.text.split("\n")[0].slice(0, 200)}`).join("\n")}` : "No messages yet.",
|
|
368
|
+
].join("\n\n") }, { triggerTurn: false });
|
|
369
|
+
}
|
|
370
|
+
/** /teams leave: hands this session's work back and drops its main-session role. */
|
|
371
|
+
async function leaveTeam(ctx: ExtensionContext) {
|
|
372
|
+
if (child) throw new Error("A member cannot leave its team; its parent stops it");
|
|
373
|
+
await link(ctx);
|
|
374
|
+
const left = { team: teamId(ctx), member: memberId(), left: true };
|
|
375
|
+
const result = connection ? await connection.request("ui", { action: "leave" }) : await coordinator!.handle(coordinator!.self, "ui", { action: "leave" });
|
|
376
|
+
pi.appendEntry(teamEntryType, left);
|
|
377
|
+
team = undefined;
|
|
378
|
+
await close(); exiting = false;
|
|
379
|
+
disable("This session left its team; use /teams join to join one");
|
|
380
|
+
ctx.ui.notify(String(result), "info");
|
|
200
381
|
}
|
|
201
382
|
pi.on("session_start", async (_event, ctx) => {
|
|
202
|
-
if (!child &&
|
|
383
|
+
if (!child && linkedSession && linkedSession !== ctx.sessionManager.getSessionId()) { await close(); drafts.clear(); }
|
|
203
384
|
context = ctx; exiting = false; inputCount = 0;
|
|
204
385
|
if (child) {
|
|
205
386
|
if (environment.some(value => !value)) throw new Error("Incomplete AC_TEAMS membership environment; refusing independent-root fallback");
|
|
206
|
-
|
|
207
|
-
connection
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
try { await connection?.request("control_result", { id: command.id, data, error }); }
|
|
221
|
-
catch { return; }
|
|
222
|
-
if (command.command === "shutdown" && !error) ctx.shutdown();
|
|
223
|
-
})();
|
|
224
|
-
});
|
|
225
|
-
await connection.open();
|
|
226
|
-
await connection.request("join", { sessionId: ctx.sessionManager.getSessionId(), sessionFile: ctx.sessionManager.getSessionFile(), tools: pi.getAllTools().map(tool => tool.name), trusted: ctx.isProjectTrusted() });
|
|
387
|
+
const link = new Connection(environment[0]!, environment[1]!, environment[2]!);
|
|
388
|
+
connection = link;
|
|
389
|
+
link.on("notice", (through, messages) => notify(through, messages));
|
|
390
|
+
link.on("disconnect", () => { warn("Team coordinator disconnected; this member is shutting down"); ctx.abort(); ctx.shutdown(); });
|
|
391
|
+
link.on("command", onCommand(ctx, link));
|
|
392
|
+
await link.open();
|
|
393
|
+
await link.request("join", { sessionId: ctx.sessionManager.getSessionId(), sessionFile: ctx.sessionManager.getSessionFile(), tools: pi.getAllTools().map(tool => tool.name), trusted: ctx.isProjectTrusted() });
|
|
394
|
+
} else if (!linkedSession) {
|
|
395
|
+
const entry = ctx.sessionManager.getEntries().filter(entry => entry.type === "custom" && entry.customType === teamEntryType).at(-1);
|
|
396
|
+
const data = entry?.type === "custom" ? entry.data as TeamEntry : undefined;
|
|
397
|
+
team = data && !data.left ? { team: data.team, member: data.member } : undefined;
|
|
398
|
+
disabled = undefined;
|
|
399
|
+
if (data?.left) disable("This session left its team; use /teams join to join one");
|
|
400
|
+
else if (team) await rejoin(ctx);
|
|
227
401
|
}
|
|
228
402
|
startTelemetry(ctx);
|
|
229
403
|
if (!warned) {
|
|
@@ -241,7 +415,7 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
241
415
|
});
|
|
242
416
|
pi.on("session_shutdown", close);
|
|
243
417
|
const switchGuard = () => {
|
|
244
|
-
if (child || coordinator?.live().length) {
|
|
418
|
+
if (child || coordinator?.live().length || connection) {
|
|
245
419
|
warn("Stop team members before switching/forking/traversing sessions (team_agent stop, or /teams stop)");
|
|
246
420
|
return { cancel: true as const };
|
|
247
421
|
}
|
|
@@ -262,12 +436,12 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
262
436
|
handler: async ctx => { if (ctx.mode === "tui") await host(ctx).visit("root"); },
|
|
263
437
|
});
|
|
264
438
|
pi.registerCommand("teams", {
|
|
265
|
-
description: "Team panel; /teams teammate <name-or-id>, config, back, stop, or
|
|
439
|
+
description: "Team panel; /teams teammate <name-or-id>, config, back, stop, status, join, or leave",
|
|
266
440
|
async getArgumentCompletions(prefix) {
|
|
267
441
|
if (!prefix.includes(" ")) return teamsCompletions(prefix, []);
|
|
268
442
|
if (!context) return null;
|
|
269
443
|
try {
|
|
270
|
-
const members = child || opening ? (await handle("agent", { action: "list" }, context) as Pick<TeamSnapshot, "members">).members : [{ id: "root", status: "idle" as const }];
|
|
444
|
+
const members = child || opening || connection ? (await handle("agent", { action: "list" }, context) as Pick<TeamSnapshot, "members">).members : [{ id: "root", status: "idle" as const }];
|
|
271
445
|
return teamsCompletions(prefix, members);
|
|
272
446
|
} catch { return null; }
|
|
273
447
|
},
|
|
@@ -279,13 +453,20 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
279
453
|
catch (error) { ctx.ui.notify(cleanText(String(error)), "error"); }
|
|
280
454
|
return;
|
|
281
455
|
}
|
|
456
|
+
if (args.trim() === "join" || args.trim() === "leave") {
|
|
457
|
+
try { await (args.trim() === "join" ? joinTeam(ctx) : leaveTeam(ctx)); }
|
|
458
|
+
catch (error) { ctx.ui.notify(cleanText(String(error instanceof Error ? error.message : error)), "error"); }
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
282
461
|
if (args.trim() === "stop") {
|
|
283
462
|
if (child) throw new Error("A member can only stop its descendants using team_agent");
|
|
284
|
-
|
|
285
|
-
|
|
463
|
+
await link(ctx);
|
|
464
|
+
// Main sessions keep running; /teams stop stops members only.
|
|
465
|
+
const { members } = await handle("agent", { action: "list" }, ctx) as Pick<TeamSnapshot, "members">;
|
|
466
|
+
for (const member of members.filter(member => member.role === "member" && !member.parent && ["starting", "busy", "idle"].includes(member.status))) await handle("agent", { action: "stop", id: member.id }, ctx);
|
|
286
467
|
}
|
|
287
468
|
if (ctx.mode === "tui" && args.trim() !== "stop" && args.trim() !== "status") {
|
|
288
|
-
if (!child) await
|
|
469
|
+
if (!child) await link(ctx);
|
|
289
470
|
const api = host(ctx);
|
|
290
471
|
if (args.trim() === "back") await api.visit("root");
|
|
291
472
|
else if (/^(teammate|open)\s+/.test(args.trim())) {
|
|
@@ -296,7 +477,7 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
296
477
|
else ctx.ui.notify(`Unknown teammate: ${cleanText(value)}. Use autocomplete or /teams panel.`, "error");
|
|
297
478
|
}
|
|
298
479
|
else if (!args.trim() || args.trim() === "panel") await showTeamsPanel(ctx, api);
|
|
299
|
-
else ctx.ui.notify("Usage: /teams [panel|teammate <name-or-id>|config|back|stop|status]", "info");
|
|
480
|
+
else ctx.ui.notify("Usage: /teams [panel|teammate <name-or-id>|config|back|stop|status|join|leave]", "info");
|
|
300
481
|
} else {
|
|
301
482
|
const status = await handle("agent", { action: "list" }, ctx);
|
|
302
483
|
ctx.ui.notify(JSON.stringify(status, null, 2), "info");
|
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;
|
package/src/schemas.ts
CHANGED
|
@@ -37,8 +37,8 @@ export const MessageSchema = Type.Object({
|
|
|
37
37
|
after: Type.Optional(Type.Integer({ minimum: 0 })), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
|
|
38
38
|
}, { additionalProperties: false });
|
|
39
39
|
const UserActionSchema = Type.Object({
|
|
40
|
-
action: Type.Union([Type.Literal("task_request"), Type.Literal("feedback"), Type.Literal("resume")]),
|
|
41
|
-
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()),
|
|
42
42
|
model: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
|
|
43
43
|
thinking: AgentSchema.properties.thinking,
|
|
44
44
|
}, { additionalProperties: false });
|
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,
|