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/coordinator.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createServer, type Server, type Socket } from "node:net";
|
|
2
2
|
import { execFile } from "node:child_process";
|
|
3
3
|
import { promisify } from "node:util";
|
|
4
|
-
import { chmodSync, closeSync, constants, existsSync, fstatSync, mkdirSync, openSync, lstatSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { chmodSync, closeSync, constants, copyFileSync, existsSync, fstatSync, mkdirSync, openSync, lstatSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
|
-
import { delimiter, dirname, join, resolve } from "node:path";
|
|
7
|
+
import { basename, delimiter, dirname, join, resolve } from "node:path";
|
|
8
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
8
9
|
import { fileURLToPath } from "node:url";
|
|
9
10
|
import { assertNarrower, assertStableGrants, mountSet, within, containerProblems, dockerCreateArgs, sandboxMounts, maxSandboxed, memberImage, packageRoot, readable, resolveAccess, sandboxStatus, type Access, type SandboxPlan, type SandboxStatus } from "./access.ts";
|
|
10
11
|
import { Egress, egressPort, providerRoutes, type Credentials } from "./egress.ts";
|
|
@@ -15,10 +16,11 @@ import { TerminalAgent, tmux } from "./terminal-agent.ts";
|
|
|
15
16
|
import { authentic, isRecord, readLines, send } from "./connection.ts";
|
|
16
17
|
import { validate } from "./schemas.ts";
|
|
17
18
|
import { createWorktree, removeWorktree } from "./worktrees.ts";
|
|
18
|
-
import {
|
|
19
|
+
import { launchMain, mainEnv, teamEntryType, handoverPrompt, type MainLaunch, type TeamEntry } from "./mains.ts";
|
|
20
|
+
import { canControl, isDescendant, isLive, isMain, memberName, taskName, type AgentArgs, type Member, type MessageArgs, type TaskArgs, type TeamState, type UserAction, type Message, type ContextUsage } from "./types.ts";
|
|
19
21
|
|
|
20
22
|
export const taskRequestGuidance = [
|
|
21
|
-
"
|
|
23
|
+
"Assess user task requests against the agreed scope and existing work before changing shared tasks.",
|
|
22
24
|
"Review the agreed scope and current task list (page as needed); read relevant descriptions, acceptance criteria, statuses, owners, and dependencies.",
|
|
23
25
|
"If an existing task already covers the request, cite its number/name and explain why; do not add a duplicate.",
|
|
24
26
|
"If a coherent extension is the smallest complete solution, update the existing task only when its state and ownership allow it; otherwise coordinate with its owner.",
|
|
@@ -34,7 +36,18 @@ export function assistantEvent(message: unknown): { type: string; text: string }
|
|
|
34
36
|
return text ? { type: "summary", text } : undefined;
|
|
35
37
|
}
|
|
36
38
|
export interface CoordinatorOptions {
|
|
39
|
+
/** Team ID: the session ID of the session that created the team. */
|
|
37
40
|
id: string; cwd: string; sessionFile?: string; limit: number; depth: number;
|
|
41
|
+
/** This session's member ID in the team (default "root", the creator); sessionId defaults to the team ID. */
|
|
42
|
+
self?: string; sessionId?: string;
|
|
43
|
+
/** Add this session to an existing team as a new main session (/teams join with no coordinator running). */
|
|
44
|
+
join?: boolean;
|
|
45
|
+
/** Another main session asked this owner session to shut down. */
|
|
46
|
+
shutdown?: () => void;
|
|
47
|
+
/** Test hook: opens a main session's terminal (default: a Herdr tab, else tmux). */
|
|
48
|
+
launchMain?: (spec: MainLaunch) => Promise<string>;
|
|
49
|
+
/** Test hook: runs after each recorded promotion step. */
|
|
50
|
+
promotionStep?: (step: string) => Promise<void> | void;
|
|
38
51
|
extension: string; home?: string; command?: string;
|
|
39
52
|
interactive?: boolean; model?: string; thinking?: string;
|
|
40
53
|
/** Version of the running Pi; members use the image built for it. Defaults to the `pi` on PATH. */
|
|
@@ -73,10 +86,13 @@ export class Coordinator {
|
|
|
73
86
|
private sandbox?: { status: SandboxStatus; at: number };
|
|
74
87
|
|
|
75
88
|
private options: CoordinatorOptions;
|
|
89
|
+
/** The main session hosting this coordinator. */
|
|
90
|
+
readonly self: string;
|
|
76
91
|
private constructor(options: CoordinatorOptions) {
|
|
77
92
|
this.options = options;
|
|
78
|
-
|
|
79
|
-
const
|
|
93
|
+
this.self = options.self ?? "root";
|
|
94
|
+
const root: Member = { id: "root", role: "main", depth: 0, token: randomUUID(), cwd: options.cwd, status: "idle", notified: 0, sessionId: options.id, sessionFile: options.sessionFile, model: options.model, thinking: options.thinking };
|
|
95
|
+
const initial: TeamState = { version: 4, id: options.id, key: randomUUID(), limit: options.limit, depth: options.depth, members: { root }, tasks: {}, messages: [] };
|
|
80
96
|
this.store = new Store(initial, options.home);
|
|
81
97
|
this.socketPath = join(this.store.dir, "coordinator.sock");
|
|
82
98
|
}
|
|
@@ -84,7 +100,7 @@ export class Coordinator {
|
|
|
84
100
|
const coordinator = new Coordinator(options);
|
|
85
101
|
try {
|
|
86
102
|
for (const member of Object.values(coordinator.store.state.members)) {
|
|
87
|
-
if (member
|
|
103
|
+
if (!isMain(member) && isLive(member) && member.pid && processAlive(member.pid, member.identity)) {
|
|
88
104
|
throw new Error(`Previous member ${member.id} is still live or uncertain; stop it explicitly before recovery`);
|
|
89
105
|
}
|
|
90
106
|
}
|
|
@@ -92,19 +108,25 @@ export class Coordinator {
|
|
|
92
108
|
state.limit = options.limit; state.depth = options.depth;
|
|
93
109
|
const names = new Set(Object.values(state.members).map(member => member.name));
|
|
94
110
|
let number = 0;
|
|
111
|
+
if (options.join) coordinator.addMain(state, coordinator.self, options.sessionId ?? options.id, options.sessionFile, options.cwd);
|
|
112
|
+
const self = state.members[coordinator.self];
|
|
113
|
+
if (!isMain(self)) throw new Error("This session is not a main session of the team");
|
|
95
114
|
for (const member of Object.values(state.members)) if (member.id !== "root" && !member.name) {
|
|
96
115
|
do { number++; } while (names.has(`teammate-${number}`) || Object.hasOwn(state.members, `teammate-${number}`));
|
|
97
116
|
member.name = `teammate-${number}`; names.add(member.name);
|
|
98
117
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
118
|
+
self.status = "idle"; delete self.error;
|
|
119
|
+
self.sessionFile = options.sessionFile ?? self.sessionFile;
|
|
120
|
+
self.model = options.model;
|
|
121
|
+
self.thinking = options.thinking;
|
|
103
122
|
for (const member of Object.values(state.members)) {
|
|
104
123
|
delete member.needsInput;
|
|
105
|
-
|
|
124
|
+
// Other main sessions reconnect as clients; until then they are not running here.
|
|
125
|
+
if (member.id !== coordinator.self && isLive(member)) member.status = "interrupted";
|
|
106
126
|
}
|
|
107
127
|
});
|
|
128
|
+
// A promotion cut short by a crash: return the member to where it started.
|
|
129
|
+
for (const member of Object.values(coordinator.store.state.members)) if (member.promotion) coordinator.rollback(member.id, "Promotion was interrupted");
|
|
108
130
|
// Owner lock held and no member process alive: containers left by a killed coordinator are orphans of this store.
|
|
109
131
|
for (const member of Object.values(coordinator.store.state.members)) if (member.access) {
|
|
110
132
|
const name = coordinator.containerName(member.id);
|
|
@@ -119,7 +141,10 @@ export class Coordinator {
|
|
|
119
141
|
coordinator.server!.listen(coordinator.socketPath, () => { chmodSync(coordinator.socketPath, 0o600); done(); });
|
|
120
142
|
});
|
|
121
143
|
coordinator.server.on("error", error => options.warning(error.message));
|
|
122
|
-
coordinator.notify(
|
|
144
|
+
coordinator.notify(coordinator.self);
|
|
145
|
+
const pass = coordinator.resumePass();
|
|
146
|
+
coordinator.operations.add(pass);
|
|
147
|
+
void pass.finally(() => coordinator.operations.delete(pass));
|
|
123
148
|
return coordinator;
|
|
124
149
|
} catch (error) { coordinator.server?.close(); coordinator.store.close(); throw error; }
|
|
125
150
|
}
|
|
@@ -133,7 +158,12 @@ export class Coordinator {
|
|
|
133
158
|
if (bound && this.sockets.get(bound) === socket) {
|
|
134
159
|
this.sockets.delete(bound);
|
|
135
160
|
if (!this.closed) {
|
|
136
|
-
this.store.update(state => {
|
|
161
|
+
this.store.update(state => {
|
|
162
|
+
const member = state.members[bound!];
|
|
163
|
+
delete member.needsInput;
|
|
164
|
+
// A main session is not a coordinator process: its socket is its liveness.
|
|
165
|
+
if (isMain(member) && isLive(member)) member.status = member.status === "stopping" ? "stopped" : "interrupted";
|
|
166
|
+
});
|
|
137
167
|
this.publishStatus();
|
|
138
168
|
}
|
|
139
169
|
for (const [id, pending] of this.controls) if (pending.member === bound) {
|
|
@@ -143,8 +173,20 @@ export class Coordinator {
|
|
|
143
173
|
});
|
|
144
174
|
readLines(socket, value => {
|
|
145
175
|
if (!isRecord(value) || typeof value.id !== "string" || value.id.length > 128 || typeof value.member !== "string" || typeof value.token !== "string" || typeof value.operation !== "string") { socket.destroy(); return; }
|
|
176
|
+
if (value.operation === "add_main" && !bound && !this.closed && authentic(value.token, this.store.state.key)) {
|
|
177
|
+
// The team key sits in the private store, so only an unsandboxed session of this user can join as a main session.
|
|
178
|
+
const args = value.args;
|
|
179
|
+
void Promise.resolve().then(() => {
|
|
180
|
+
if (!isRecord(args) || typeof args.sessionId !== "string" || typeof args.cwd !== "string" || (args.sessionFile !== undefined && typeof args.sessionFile !== "string")) throw new Error("Invalid join request");
|
|
181
|
+
const id = randomUUID().slice(0, 16);
|
|
182
|
+
this.store.update(state => this.addMain(state, id, args.sessionId as string, args.sessionFile as string | undefined, args.cwd as string));
|
|
183
|
+
return { id, token: this.store.state.members[id].token };
|
|
184
|
+
}).then(data => send(socket, { id: value.id, data }), error => send(socket, { id: value.id, error: String(error instanceof Error ? error.message : error) }));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
146
187
|
const member = this.store.state.members[value.member];
|
|
147
|
-
|
|
188
|
+
const main = isMain(member);
|
|
189
|
+
if ((this.closed && value.operation !== "control_result") || !member || !(main || isLive(member)) || !authentic(value.token, member.token) || member.id === this.self || (bound && bound !== member.id)) { socket.destroy(); return; }
|
|
148
190
|
bound = member.id;
|
|
149
191
|
const operation = value.operation;
|
|
150
192
|
const run = async () => {
|
|
@@ -153,6 +195,19 @@ export class Coordinator {
|
|
|
153
195
|
if (previous && previous !== socket && !previous.destroyed) throw new Error("Member already connected");
|
|
154
196
|
const args = value.args;
|
|
155
197
|
if (!isRecord(args) || typeof args.sessionId !== "string" || typeof args.sessionFile !== "string" || !Array.isArray(args.tools) || !["team_agent", "team_task", "team_message"].every(name => Array.isArray(args.tools) && args.tools.includes(name))) throw new Error("Incomplete ac-teams readiness handshake");
|
|
198
|
+
if (main) {
|
|
199
|
+
// A main session's identity never changes; its transcript may live anywhere the user keeps sessions.
|
|
200
|
+
if (member.sessionId && args.sessionId !== member.sessionId) throw new Error("This main session's record belongs to another Pi session");
|
|
201
|
+
this.store.update(state => {
|
|
202
|
+
const current = state.members[member.id];
|
|
203
|
+
current.status = "idle"; delete current.error;
|
|
204
|
+
current.sessionId = args.sessionId as string; current.sessionFile = args.sessionFile as string;
|
|
205
|
+
});
|
|
206
|
+
this.sockets.set(member.id, socket);
|
|
207
|
+
this.publishStatus();
|
|
208
|
+
this.notify(member.id);
|
|
209
|
+
return { id: this.store.state.id, member: this.publicMember(this.store.state.members[member.id]), limit: this.store.state.limit, depth: this.store.state.depth };
|
|
210
|
+
}
|
|
156
211
|
// The session file chooses a host mount on resume, so it must stay in the directory ac-teams assigned.
|
|
157
212
|
const assigned = this.sessionDirs.get(member.id) ?? this.memberSessionDir(member.id);
|
|
158
213
|
if (!assigned || !within(resolve(args.sessionFile as string), assigned)) throw new Error("Member session file is outside its assigned directory");
|
|
@@ -230,8 +285,8 @@ export class Coordinator {
|
|
|
230
285
|
else if (event.type === "model_error") current.error = event.text || "Model request failed";
|
|
231
286
|
});
|
|
232
287
|
this.publishStatus();
|
|
233
|
-
if (event.type === "model_error" &&
|
|
234
|
-
const recipient = member.parent && isLive(this.store.state.members[member.parent]) ? member.parent :
|
|
288
|
+
if (event.type === "model_error" && !isMain(member) && member.status !== "stopping") {
|
|
289
|
+
const recipient = member.parent && isLive(this.store.state.members[member.parent]) ? member.parent : this.self;
|
|
235
290
|
this.store.message(actor, { action: "send", recipients: [recipient], text: `Member ${actor} model error: ${event.text || "Model request failed"}` });
|
|
236
291
|
this.notify(recipient);
|
|
237
292
|
}
|
|
@@ -243,7 +298,7 @@ export class Coordinator {
|
|
|
243
298
|
if (!member) throw new Error("Unknown member ID");
|
|
244
299
|
if (member.status === "stopping") throw new Error("Member is stopping; retry after it stops");
|
|
245
300
|
if (isLive(member)) return;
|
|
246
|
-
const launch = this.launch(
|
|
301
|
+
const launch = this.launch(this.self, { action: "resume", id, brief: "Resume the native session without replaying work.", model: member.model ?? args.model, thinking: member.thinking ?? args.thinking }, false);
|
|
247
302
|
this.operations.add(launch);
|
|
248
303
|
try { await launch; } finally { this.operations.delete(launch); }
|
|
249
304
|
};
|
|
@@ -251,22 +306,30 @@ export class Coordinator {
|
|
|
251
306
|
if (!args.id) throw new Error("A member ID is required");
|
|
252
307
|
await resume(args.id); return `Resumed ${args.id}`;
|
|
253
308
|
}
|
|
309
|
+
// Promoting and leaving change authority, so only the user, from a main session's TUI, can do them.
|
|
310
|
+
if (args.action === "promote" || args.action === "leave") {
|
|
311
|
+
if (!isMain(this.store.state.members[actor])) throw new Error("Only a main session can promote members or leave the team");
|
|
312
|
+
const run = args.action === "promote" ? this.promote(args.id ?? "", args.confirmed === true) : Promise.resolve(this.leave(actor));
|
|
313
|
+
this.operations.add(run);
|
|
314
|
+
try { return await run; } finally { this.operations.delete(run); }
|
|
315
|
+
}
|
|
254
316
|
if (!args.text?.trim()) throw new Error("Write a request or feedback before sending");
|
|
255
|
-
|
|
317
|
+
const main = isMain(this.store.state.members[actor]) ? actor : this.self;
|
|
318
|
+
let recipient = main, fallback = "";
|
|
256
319
|
let text = `User task request (review before changing tasks):\n${taskRequestGuidance}\n\nRequest:\n${args.text}`;
|
|
257
320
|
if (args.action === "feedback") {
|
|
258
321
|
const task = this.store.state.tasks[args.id ?? ""];
|
|
259
322
|
if (!task) throw new Error("Unknown task ID");
|
|
260
|
-
recipient = task.creator
|
|
323
|
+
recipient = task.creator && this.store.state.members[task.creator]?.role ? task.creator : main;
|
|
261
324
|
text = `User feedback on task ${taskName(task)} [${task.id}] (${task.title}):\n${args.text}`;
|
|
262
325
|
if (!isLive(this.store.state.members[recipient]) || this.store.state.members[recipient].status === "stopping") {
|
|
263
326
|
try { await resume(recipient); }
|
|
264
|
-
catch (error) { fallback = `Creator ${recipient} could not resume (${String(error)}); delivered to
|
|
327
|
+
catch (error) { fallback = `Creator ${recipient} could not resume (${String(error)}); delivered to ${memberName(this.store.state.members[main])} instead. `; recipient = main; }
|
|
265
328
|
}
|
|
266
329
|
}
|
|
267
330
|
const message = this.store.message(actor, { action: "send", recipients: [recipient], text }, true) as Message;
|
|
268
331
|
this.notify(recipient);
|
|
269
|
-
return `${fallback}Sent to ${recipient
|
|
332
|
+
return `${fallback}Sent to ${isMain(this.store.state.members[recipient]) ? memberName(this.store.state.members[recipient]) : recipient} · message ${message.seq}`;
|
|
270
333
|
}
|
|
271
334
|
private publicMember(member: Member) {
|
|
272
335
|
const { token: _token, identity: _identity, ...view } = member;
|
|
@@ -282,7 +345,8 @@ export class Coordinator {
|
|
|
282
345
|
}
|
|
283
346
|
return this.sandbox.status;
|
|
284
347
|
}
|
|
285
|
-
|
|
348
|
+
/** Live members this coordinator runs; main sessions run in their own terminals and never count. */
|
|
349
|
+
live(): Member[] { return Object.values(this.store.state.members).filter(member => !isMain(member) && isLive(member)); }
|
|
286
350
|
ack(id: string, through: number): void {
|
|
287
351
|
const last = this.store.state.messages.at(-1)?.seq ?? 0;
|
|
288
352
|
if (!Number.isSafeInteger(through) || through < 0 || through > last) throw new Error("Invalid notification cursor");
|
|
@@ -298,7 +362,7 @@ export class Coordinator {
|
|
|
298
362
|
const through = pending.at(-1)?.seq;
|
|
299
363
|
if (!through) return;
|
|
300
364
|
try {
|
|
301
|
-
if (id ===
|
|
365
|
+
if (id === this.self) this.options.notice(through, pending.slice(-5));
|
|
302
366
|
else { const socket = this.sockets.get(id); if (socket) send(socket, { type: "notice", through, messages: pending.slice(-5) }); }
|
|
303
367
|
} catch (error) { this.options.warning(`Team notification remains in history: ${String(error)}`); }
|
|
304
368
|
}, 50));
|
|
@@ -326,7 +390,7 @@ export class Coordinator {
|
|
|
326
390
|
const state = this.store.state;
|
|
327
391
|
if (args.action === "list") return { team: state.id, limit: state.limit, depth: state.depth, sandbox: this.sandboxStatus(), lastMessage: state.messages.at(-1)?.seq ?? 0, members: Object.values(state.members).map(member => this.publicMember(member)) };
|
|
328
392
|
if (args.action === "limits") {
|
|
329
|
-
if (actor
|
|
393
|
+
if (!isMain(state.members[actor])) throw new Error("Only a main session can change team limits");
|
|
330
394
|
if ((args.limit ?? state.limit) > 10 && !authorizedLimit) throw new Error("A ceiling above 10 requires explicit user confirmation or --teams-limit");
|
|
331
395
|
this.store.update(next => { next.limit = args.limit ?? next.limit; next.depth = args.depth ?? next.depth; });
|
|
332
396
|
return { limit: this.store.state.limit, depth: this.store.state.depth, sandbox: this.sandboxStatus() };
|
|
@@ -339,7 +403,7 @@ export class Coordinator {
|
|
|
339
403
|
const member = args.id ? state.members[args.id] : undefined;
|
|
340
404
|
if (!member) throw new Error("Unknown member ID");
|
|
341
405
|
if (args.action === "status") return this.publicMember(member);
|
|
342
|
-
if (
|
|
406
|
+
if (!canControl(state, actor, member.id)) throw new Error("Only a main session or a member's ancestor can stop or clean it up");
|
|
343
407
|
if (args.action === "cleanup") {
|
|
344
408
|
if (state.members[actor].access) throw new Error("Sandboxed members cannot remove worktrees");
|
|
345
409
|
if (isLive(member)) throw new Error("Stop the member before worktree cleanup");
|
|
@@ -355,7 +419,8 @@ export class Coordinator {
|
|
|
355
419
|
this.operations.add(cleanup);
|
|
356
420
|
try { return await cleanup; } finally { this.cleaning.delete(member.id); this.operations.delete(cleanup); }
|
|
357
421
|
}
|
|
358
|
-
await this.
|
|
422
|
+
if (isMain(member)) await this.stopMain(member.id);
|
|
423
|
+
else await this.stopTree(member.id);
|
|
359
424
|
return this.publicMember(this.store.state.members[member.id]);
|
|
360
425
|
}
|
|
361
426
|
private async launch(actor: string, args: AgentArgs, prompt = true): Promise<unknown> {
|
|
@@ -364,7 +429,8 @@ export class Coordinator {
|
|
|
364
429
|
if (!args.brief?.trim()) throw new Error("A launch or resume brief is required");
|
|
365
430
|
if (!args.model || !args.model.includes("/")) throw new Error("A provider/model is required (normally inherited from the requesting member)");
|
|
366
431
|
const resumed = args.action === "resume" ? state.members[args.id ?? ""] : undefined;
|
|
367
|
-
if (args.action === "resume" && (!resumed ||
|
|
432
|
+
if (args.action === "resume" && (!resumed || !resumed.sessionFile || !canControl(state, actor, resumed.id))) throw new Error("Resume requires a stopped member you control with a native session file");
|
|
433
|
+
if (resumed && isMain(resumed)) return this.resumeMain(resumed.id);
|
|
368
434
|
if (resumed && this.cleaning.has(resumed.id)) throw new Error("Worktree cleanup is running; cannot resume this member");
|
|
369
435
|
if (resumed && (isLive(resumed) || (resumed.pid && processAlive(resumed.pid, resumed.identity)))) throw new Error("Cannot resume a live or uncertain member");
|
|
370
436
|
const depth = resumed ? resumed.depth : parent.depth + 1;
|
|
@@ -388,7 +454,7 @@ export class Coordinator {
|
|
|
388
454
|
const provider = args.model.slice(0, args.model.indexOf("/"));
|
|
389
455
|
const locked = resumed ? resumed.provider : parent.access?.network.allow ? parent.provider : undefined;
|
|
390
456
|
if (access?.network.allow && locked && provider !== locked) throw new Error(`A member with restricted network access can only use ${locked} models`);
|
|
391
|
-
const member: Member = resumed ? { ...resumed, token: randomUUID(), status: "starting", error: undefined, contextUsage: undefined, modelName: undefined } : { id, name: args.name, parent: actor, depth, token: randomUUID(), cwd, status: "starting", notified: 0, access, provider: access ? provider : undefined };
|
|
457
|
+
const member: Member = resumed ? { ...resumed, token: randomUUID(), status: "starting", error: undefined, contextUsage: undefined, modelName: undefined, handover: undefined } : { id, name: args.name, role: "member", parent: actor, depth, token: randomUUID(), cwd, status: "starting", notified: 0, access, provider: access ? provider : undefined };
|
|
392
458
|
// Reserve synchronously before the first await so concurrent launches cannot oversubscribe.
|
|
393
459
|
this.store.update(next => { next.members[id] = member; });
|
|
394
460
|
this.publishStatus();
|
|
@@ -419,7 +485,8 @@ export class Coordinator {
|
|
|
419
485
|
this.sessionDirs.set(id, sessions);
|
|
420
486
|
const rpcArgs = [...(this.options.interactive ? [] : ["--mode", "rpc"]), "--session-dir", sessions, "-e", this.options.extension, "--provider", provider, "--model", modelId, "--thinking", args.thinking ?? "off"];
|
|
421
487
|
if (resumed?.sessionFile) rpcArgs.push("--session", resumed.sessionFile);
|
|
422
|
-
|
|
488
|
+
// ACSTACK_AGENT_ROLE tells ACStack hooks this is not a main session; children inherit it.
|
|
489
|
+
const identity = { HERDR_ENV: "0", ACSTACK_AGENT_ROLE: "member", AC_TEAMS_SOCKET: this.socketPath, AC_TEAMS_MEMBER: id, AC_TEAMS_TOKEN: member.token, AC_TEAMS_ACCESS: JSON.stringify(access ? { enforced: true, ...access } : { enforced: false }) };
|
|
423
490
|
const spawnOptions = access
|
|
424
491
|
? await this.sandboxed(id, access, member.cwd, provider, modelId, rpcArgs, identity, sessions)
|
|
425
492
|
: { cwd: member.cwd, args: rpcArgs, command: this.options.command, env: { ...process.env, ...identity, AC_TEAMS_TERMINAL_SOCKET: join(this.store.dir, "t.sock") } };
|
|
@@ -447,7 +514,8 @@ export class Coordinator {
|
|
|
447
514
|
if (this.closed) return;
|
|
448
515
|
this.store.update(next => { next.members[id].status = next.members[id].status === "stopping" ? "stopped" : "interrupted"; delete next.members[id].needsInput; });
|
|
449
516
|
this.publishStatus();
|
|
450
|
-
|
|
517
|
+
// A promoted member keeps its descendants.
|
|
518
|
+
if (!this.store.state.members[id].promotion) void this.stopDescendants(id).catch(error => this.options.warning(String(error)));
|
|
451
519
|
});
|
|
452
520
|
if (agent instanceof TerminalAgent) await agent.start();
|
|
453
521
|
member.pid = agent.process.pid;
|
|
@@ -474,6 +542,129 @@ export class Coordinator {
|
|
|
474
542
|
throw error;
|
|
475
543
|
}
|
|
476
544
|
}
|
|
545
|
+
/** Adds a main session record; the caller writes the team entry into its transcript. */
|
|
546
|
+
private addMain(state: TeamState, id: string, sessionId: string, sessionFile: string | undefined, cwd: string): void {
|
|
547
|
+
if (Object.values(state.members).some(member => member.sessionId === sessionId)) throw new Error("This Pi session is already in the team");
|
|
548
|
+
const names = new Set(Object.values(state.members).map(member => member.name));
|
|
549
|
+
let number = 1;
|
|
550
|
+
do { number++; } while (names.has(`main-${number}`));
|
|
551
|
+
state.members[id] = { id, name: `main-${number}`, role: "main", depth: 0, token: randomUUID(), cwd, status: "stopped", notified: state.messages.at(-1)?.seq ?? 0, sessionId, sessionFile };
|
|
552
|
+
}
|
|
553
|
+
/** Restores members a previous owner stopped to the state it recorded. */
|
|
554
|
+
private async resumePass(): Promise<void> {
|
|
555
|
+
for (const member of Object.values(this.store.state.members)) {
|
|
556
|
+
const state = member.handover;
|
|
557
|
+
if (!state || isLive(member)) continue;
|
|
558
|
+
try {
|
|
559
|
+
if (!member.model) throw new Error("no recorded model");
|
|
560
|
+
await this.launch(this.self, { action: "resume", id: member.id, brief: state === "busy" ? handoverPrompt : "Resume the native session without replaying work.", model: member.model, thinking: member.thinking }, state === "busy");
|
|
561
|
+
if (state === "input") this.recordEvent(member.id, { type: "blocked", active: true });
|
|
562
|
+
} catch (error) {
|
|
563
|
+
if (this.closed) return;
|
|
564
|
+
this.options.warning(`Could not resume ${memberName(member)} after the coordinator handover: ${error instanceof Error ? error.message : String(error)}`);
|
|
565
|
+
if (this.store.state.members[member.id]?.handover) this.store.update(next => { delete next.members[member.id].handover; });
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
/** The top-level session folder: Main's session directory without ac-teams nesting. */
|
|
570
|
+
private topSessionDir(): string {
|
|
571
|
+
return resolve(this.options.sessionDir ?? join(this.store.dir, "sessions")).replace(/\/ac-teams\/[^/]+\/[^/]+$/, "");
|
|
572
|
+
}
|
|
573
|
+
/** Opens a main session's transcript in its own terminal with a clean environment. */
|
|
574
|
+
private async openMain(id: string): Promise<string> {
|
|
575
|
+
const member = this.store.state.members[id];
|
|
576
|
+
const notice = await (this.options.launchMain ?? launchMain)({
|
|
577
|
+
id, name: memberName(member), cwd: member.cwd, socket: join(this.store.dir, `m-${id}.sock`), env: mainEnv(),
|
|
578
|
+
command: [this.options.command ?? process.env.AC_TEAMS_PI ?? "pi", "--session", member.sessionFile!],
|
|
579
|
+
});
|
|
580
|
+
this.store.update(state => { state.members[id].status = "starting"; delete state.members[id].error; });
|
|
581
|
+
return notice;
|
|
582
|
+
}
|
|
583
|
+
private async resumeMain(id: string): Promise<string> {
|
|
584
|
+
const member = this.store.state.members[id];
|
|
585
|
+
if (isLive(member) || this.sockets.has(id)) throw new Error("That main session is already running");
|
|
586
|
+
return this.openMain(id);
|
|
587
|
+
}
|
|
588
|
+
private async stopMain(id: string): Promise<void> {
|
|
589
|
+
if (id === this.self) {
|
|
590
|
+
if (!this.options.shutdown) throw new Error("This main session cannot be stopped remotely");
|
|
591
|
+
this.options.shutdown(); return;
|
|
592
|
+
}
|
|
593
|
+
if (!this.sockets.has(id)) throw new Error("That main session is not running");
|
|
594
|
+
this.store.update(state => { state.members[id].status = "stopping"; });
|
|
595
|
+
await this.control(id, "shutdown");
|
|
596
|
+
}
|
|
597
|
+
/** Stops a member and relaunches its transcript as a main session. Each step is recorded so a failure can roll back. */
|
|
598
|
+
private async promote(id: string, confirmed: boolean): Promise<string> {
|
|
599
|
+
const member = this.store.state.members[id];
|
|
600
|
+
if (!member || member.role !== "member") throw new Error("Only a member can be promoted");
|
|
601
|
+
if (!member.sessionFile) throw new Error("The member has no native session file yet");
|
|
602
|
+
if (member.promotion) throw new Error("The member is already being promoted");
|
|
603
|
+
if (member.access && !confirmed) throw new Error("Promoting a sandboxed member runs it with your full access; confirm before promoting");
|
|
604
|
+
const original = member.sessionFile;
|
|
605
|
+
const step = async (name: NonNullable<Member["promotion"]>["step"], change: (current: Member) => void = () => {}) => {
|
|
606
|
+
this.store.update(state => { const current = state.members[id]; change(current); current.promotion = { ...current.promotion!, step: name }; });
|
|
607
|
+
await this.options.promotionStep?.(name);
|
|
608
|
+
};
|
|
609
|
+
this.store.update(state => { state.members[id].promotion = { step: "stopped", sessionFile: original, parent: member.parent, access: member.access, provider: member.provider }; });
|
|
610
|
+
try {
|
|
611
|
+
await this.stopOne(id);
|
|
612
|
+
await step("stopped");
|
|
613
|
+
const target = join(this.topSessionDir(), basename(original));
|
|
614
|
+
if (existsSync(target)) throw new Error(`A session file already exists at ${target}`);
|
|
615
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
616
|
+
moveFile(original, target);
|
|
617
|
+
await step("moved", current => { current.sessionFile = target; });
|
|
618
|
+
rmSync(memberConfigDir(id), { recursive: true, force: true });
|
|
619
|
+
await step("role", current => {
|
|
620
|
+
current.role = "main"; current.depth = 0; current.token = randomUUID();
|
|
621
|
+
delete current.parent; delete current.access; delete current.provider; delete current.handover;
|
|
622
|
+
});
|
|
623
|
+
this.store.update(recomputeDepths);
|
|
624
|
+
const entry: TeamEntry = { team: this.store.state.id, member: id };
|
|
625
|
+
SessionManager.open(target).appendCustomEntry(teamEntryType, entry);
|
|
626
|
+
await step("entry");
|
|
627
|
+
const notice = await this.openMain(id);
|
|
628
|
+
this.store.update(state => { delete state.members[id].promotion; });
|
|
629
|
+
return `Promoted ${memberName(member)} to a main session. ${notice}`;
|
|
630
|
+
} catch (error) {
|
|
631
|
+
this.rollback(id, error instanceof Error ? error.message : String(error));
|
|
632
|
+
throw error;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
/** Returns a partly promoted member to a stopped member with its original transcript path. */
|
|
636
|
+
private rollback(id: string, reason: string): void {
|
|
637
|
+
const promotion = this.store.state.members[id].promotion!;
|
|
638
|
+
const current = this.store.state.members[id].sessionFile;
|
|
639
|
+
try {
|
|
640
|
+
if (current && current !== promotion.sessionFile && existsSync(current)) {
|
|
641
|
+
// The moved transcript may already claim main-session membership; record the departure first.
|
|
642
|
+
if (promotion.step === "entry") SessionManager.open(current).appendCustomEntry(teamEntryType, { team: this.store.state.id, member: id, left: true } satisfies TeamEntry);
|
|
643
|
+
moveFile(current, promotion.sessionFile);
|
|
644
|
+
}
|
|
645
|
+
} catch (error) { this.options.warning(`Promotion rollback could not restore ${promotion.sessionFile}: ${String(error)}`); }
|
|
646
|
+
this.store.update(state => {
|
|
647
|
+
const member = state.members[id];
|
|
648
|
+
Object.assign(member, { role: "member", sessionFile: promotion.sessionFile, parent: promotion.parent, access: promotion.access, provider: promotion.provider, status: "stopped", error: `Promotion failed: ${reason}`.slice(0, 2000) });
|
|
649
|
+
for (const key of ["parent", "access", "provider"] as const) if (member[key] === undefined) delete member[key];
|
|
650
|
+
if (member.parent && !state.members[member.parent]) delete member.parent;
|
|
651
|
+
delete member.promotion;
|
|
652
|
+
recomputeDepths(state);
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
/** A main session leaves: its in-progress tasks return to todo and its record keeps history without a role. */
|
|
656
|
+
private leave(actor: string): string {
|
|
657
|
+
const state = this.store.state;
|
|
658
|
+
const others = Object.values(state.members).filter(member => isMain(member) && member.id !== actor);
|
|
659
|
+
if (!others.length && this.live().length) throw new Error("You are the only main session and members are still live; stop them first");
|
|
660
|
+
this.store.update(next => {
|
|
661
|
+
for (const task of Object.values(next.tasks)) if (task.owner === actor && task.status !== "done") { task.status = "todo"; delete task.owner; }
|
|
662
|
+
const member = next.members[actor];
|
|
663
|
+
delete member.role;
|
|
664
|
+
member.status = "stopped";
|
|
665
|
+
});
|
|
666
|
+
return `Left team ${state.id}`;
|
|
667
|
+
}
|
|
477
668
|
private image() {
|
|
478
669
|
return memberImage(this.options.piVersion ?? JSON.parse(readFileSync(join(packageRoot(command("pi")), "package.json"), "utf8")).version);
|
|
479
670
|
}
|
|
@@ -624,6 +815,13 @@ export class Coordinator {
|
|
|
624
815
|
private async closeOnce(): Promise<void> {
|
|
625
816
|
this.closed = true;
|
|
626
817
|
for (const timer of this.notices.values()) clearTimeout(timer);
|
|
818
|
+
// The next owner's resume pass restores each member to the state recorded here.
|
|
819
|
+
this.store.update(state => {
|
|
820
|
+
for (const member of Object.values(state.members)) if (!isMain(member) && isLive(member) && member.status !== "stopping") {
|
|
821
|
+
member.handover = member.needsInput ? "input" : member.status === "idle" ? "idle" : "busy";
|
|
822
|
+
}
|
|
823
|
+
if (state.members[this.self]?.status !== undefined && isLive(state.members[this.self])) state.members[this.self].status = "stopped";
|
|
824
|
+
});
|
|
627
825
|
for (const member of this.live().sort((a, b) => b.depth - a.depth)) await this.stopOne(member.id);
|
|
628
826
|
// Finish recording worktree operations already in flight before the store closes.
|
|
629
827
|
await Promise.allSettled([...this.operations]);
|
|
@@ -644,6 +842,19 @@ export class Coordinator {
|
|
|
644
842
|
|
|
645
843
|
const memberConfigDir = (id: string) => join(stateRoot(), "agents", id);
|
|
646
844
|
|
|
845
|
+
function moveFile(from: string, to: string): void {
|
|
846
|
+
try { renameSync(from, to); }
|
|
847
|
+
catch (error) {
|
|
848
|
+
if ((error as NodeJS.ErrnoException).code !== "EXDEV") throw error;
|
|
849
|
+
copyFileSync(from, to, constants.COPYFILE_EXCL); unlinkSync(from);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
/** Depth follows ancestry, so a promotion moves the promoted member's descendants up. */
|
|
853
|
+
function recomputeDepths(state: TeamState): void {
|
|
854
|
+
const depth = (member: Member, seen = 0): number => member.parent && state.members[member.parent] && seen < 64 ? depth(state.members[member.parent], seen + 1) + 1 : 0;
|
|
855
|
+
for (const member of Object.values(state.members)) member.depth = depth(member);
|
|
856
|
+
}
|
|
857
|
+
|
|
647
858
|
function command(name: string): string {
|
|
648
859
|
if (name.includes("/")) return realpathSync(name);
|
|
649
860
|
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|