ac-teams 0.0.2 → 0.0.4
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 +3 -3
- package/docs/usage.md +9 -3
- package/package.json +1 -1
- package/src/coordinator.ts +35 -8
- package/src/index.ts +23 -6
- package/src/panel.ts +5 -3
- package/src/schemas.ts +4 -2
- package/src/store.ts +5 -3
- package/src/terminal-agent.ts +1 -1
- package/src/terminal-ui.ts +59 -42
- package/src/types.ts +1 -0
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Persistent agent teams for [Pi](https://github.com/earendil-works/pi): shared ta
|
|
|
7
7
|
Requires Linux, Node 22.19 or newer, and an authenticated `pi` executable on `PATH`. Interactive teams also require `tmux` on `PATH`. Tested with Pi 1.4.0.
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
|
-
pi install npm:ac-teams@0.0.
|
|
10
|
+
pi install npm:ac-teams@0.0.4
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
Restart Pi after installation. Use Pi's `/trust` command to save approval for each project directory before launching members there; headless children cannot answer the initial trust prompt.
|
|
@@ -26,9 +26,9 @@ Your Pi session is the root; its child agents are members. Ask Pi to delegate a
|
|
|
26
26
|
|
|
27
27
|
`/teams` or `/teams panel` opens Tasks, Teammates, and Activity. `/teams teammate <name-or-id>` opens a teammate directly; subcommands and teammate names autocomplete. Use the task graph or list, send feedback, and open a member's full live Pi session.
|
|
28
28
|
|
|
29
|
-
The task summary sits above the input. The agent selector below
|
|
29
|
+
The task summary sits above the input. The agent selector sits below the input, above your existing footer, and shows descriptive names, models, and context usage. ac-teams publishes a compact team status without replacing the footer or editor supplied by other extensions. `/teams config` toggles these displays independently.
|
|
30
30
|
|
|
31
|
-
From an empty editor, press Down to select the team
|
|
31
|
+
From an empty editor, press Down to select the team row, then Enter to open a member. A red **Needs input** label marks a member with an open question. Ctrl+Shift+Left returns to Main without stopping the member or dismissing its question. `/teams stop` stops every descendant.
|
|
32
32
|
|
|
33
33
|
Defaults allow up to **10 live members** and **two levels of delegation**. Use the fewest useful agents.
|
|
34
34
|
|
package/docs/usage.md
CHANGED
|
@@ -12,11 +12,15 @@ In interactive Pi, `/teams` or `/teams panel` opens the team panel. `/teams team
|
|
|
12
12
|
|
|
13
13
|
## Navigate without stopping members
|
|
14
14
|
|
|
15
|
-
The agent selector sits below
|
|
15
|
+
The agent selector sits below the input editor, above the existing footer. It shows each member's name, state, model display name, and context usage. It keeps Main pinned and shows at most five rows, including an overflow indicator when needed. A finished assignment leaves its member idle and selectable.
|
|
16
|
+
|
|
17
|
+
A red **Needs input** label beside a member's name marks an open question. Open that member's Pi session to answer it. Ctrl+Shift+Left returns to Main without dismissing the question.
|
|
18
|
+
|
|
19
|
+
With Herdr's Pi integration and the question adapter installed, Main reports the team's status through the shared event bus. Any open question keeps Herdr blocked; otherwise, active work keeps it working. When all members settle, it becomes idle. Members do not report directly to the shared Herdr pane.
|
|
16
20
|
|
|
17
21
|
Context shows Pi's reported token estimate, window size, and percentage, for example `Claude Opus 4.6 · 48k / 200k (24%)`. Reports refresh about once a second when they change. Unknown usage stays marked unknown, not zero. Stopped members retain their last report. These are context estimates, not billing totals.
|
|
18
22
|
|
|
19
|
-
- Press Down from an empty editor to focus Main in the
|
|
23
|
+
- Press Down from an empty editor to focus Main in the agent selector. Up/Down select members; Enter opens the selected live session; Esc returns to editing.
|
|
20
24
|
- Ctrl+Shift+Left returns from a member to Main. Switching preserves the member's process, session, and draft. Returning does not stop it.
|
|
21
25
|
- `/teams` opens three tabs. Tab moves between Tasks, Teammates, and Activity; Esc closes the panel.
|
|
22
26
|
- **Tasks:** Arrow keys select nodes in the dependency graph. G toggles graph/list, and Enter shows full details. Green means Done, red Blocked, cyan Working, and neutral Ready; written labels remain visible. Large or unsupported graphs fall back to the list.
|
|
@@ -32,7 +36,7 @@ Team tool results and messages are compact by default. Ctrl+O expands them into
|
|
|
32
36
|
|
|
33
37
|
## Show tasks and configure the displays
|
|
34
38
|
|
|
35
|
-
The task summary sits above the input editor; the agent selector
|
|
39
|
+
The task summary sits above the input editor; the agent selector sits below the editor. The task summary shows completed/total counts and up to five numbered task labels, ordered by:
|
|
36
40
|
|
|
37
41
|
1. In-progress work
|
|
38
42
|
2. Ready prerequisites blocking other tasks, with the most dependents first
|
|
@@ -41,6 +45,8 @@ The task summary sits above the input editor; the agent selector stays below the
|
|
|
41
45
|
|
|
42
46
|
Ordinary ready tasks remain in `/teams`; the summary is not the full task list. Completion time determines recent order for newly completed tasks. Older completed tasks without timestamps fall back to task-number order.
|
|
43
47
|
|
|
48
|
+
ac-teams leaves your existing footer intact and publishes a compact summary under the `ac-teams` extension-status key. Custom footers must support extension statuses to show it. In Zentui, use `/zentui extensions` to choose a placement for `ac-teams` if statuses are hidden by default. Agent navigation remains available in its own row even when the footer hides statuses.
|
|
49
|
+
|
|
44
50
|
Use `/teams config` to toggle **Task summary** and **Agent selector** independently. Up/Down select a setting; Space or Enter toggles it; Esc closes. Changes save immediately for the current and future sessions. Other running sessions are not updated automatically. Both displays default to On.
|
|
45
51
|
|
|
46
52
|
Hiding a display does not stop agents, change tasks, or disable `/teams`. With the agent selector hidden, ordinary editor Down behavior returns. These global display preferences do not change the team's resource ceilings.
|
package/package.json
CHANGED
package/src/coordinator.ts
CHANGED
|
@@ -32,6 +32,7 @@ export interface CoordinatorOptions {
|
|
|
32
32
|
interactive?: boolean; model?: string; thinking?: string;
|
|
33
33
|
notice: (through: number, messages: Message[]) => void;
|
|
34
34
|
warning: (message: string) => void;
|
|
35
|
+
status?: (kind: "busy" | "blocked", active: boolean) => void;
|
|
35
36
|
}
|
|
36
37
|
export class Coordinator {
|
|
37
38
|
readonly store: Store;
|
|
@@ -46,6 +47,7 @@ export class Coordinator {
|
|
|
46
47
|
private notices = new Map<string, NodeJS.Timeout>();
|
|
47
48
|
private operations = new Set<Promise<unknown>>();
|
|
48
49
|
private closed = false;
|
|
50
|
+
private status = { busy: false, blocked: false };
|
|
49
51
|
private closePromise?: Promise<void>;
|
|
50
52
|
|
|
51
53
|
private options: CoordinatorOptions;
|
|
@@ -76,7 +78,10 @@ export class Coordinator {
|
|
|
76
78
|
state.members.root.sessionFile = options.sessionFile;
|
|
77
79
|
state.members.root.model = options.model;
|
|
78
80
|
state.members.root.thinking = options.thinking;
|
|
79
|
-
for (const member of Object.values(state.members))
|
|
81
|
+
for (const member of Object.values(state.members)) {
|
|
82
|
+
delete member.needsInput;
|
|
83
|
+
if (member.id !== "root" && isLive(member)) member.status = "interrupted";
|
|
84
|
+
}
|
|
80
85
|
});
|
|
81
86
|
try { unlinkSync(coordinator.socketPath); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
82
87
|
coordinator.server = createServer(socket => coordinator.accept(socket));
|
|
@@ -98,6 +103,10 @@ export class Coordinator {
|
|
|
98
103
|
this.connections.delete(socket);
|
|
99
104
|
if (bound && this.sockets.get(bound) === socket) {
|
|
100
105
|
this.sockets.delete(bound);
|
|
106
|
+
if (!this.closed) {
|
|
107
|
+
this.store.update(state => { delete state.members[bound!].needsInput; });
|
|
108
|
+
this.publishStatus();
|
|
109
|
+
}
|
|
101
110
|
for (const [id, pending] of this.controls) if (pending.member === bound) {
|
|
102
111
|
clearTimeout(pending.timer); this.controls.delete(id); pending.reject(new Error("Native member disconnected"));
|
|
103
112
|
}
|
|
@@ -157,25 +166,38 @@ export class Coordinator {
|
|
|
157
166
|
send(socket, { type: "command", id, command, args });
|
|
158
167
|
});
|
|
159
168
|
}
|
|
169
|
+
private publishStatus(): void {
|
|
170
|
+
const members = this.live().filter(member => member.status !== "stopping");
|
|
171
|
+
const next = {
|
|
172
|
+
busy: members.some(member => member.status === "starting" || member.status === "busy"),
|
|
173
|
+
blocked: members.some(member => member.needsInput),
|
|
174
|
+
};
|
|
175
|
+
for (const kind of ["busy", "blocked"] as const) if (next[kind] !== this.status[kind]) {
|
|
176
|
+
this.status[kind] = next[kind];
|
|
177
|
+
this.options.status?.(kind, next[kind]);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
160
180
|
recordEvent(actor: string, value: unknown): void {
|
|
161
181
|
validate("event", value);
|
|
162
182
|
if (this.closed) return;
|
|
163
|
-
const event = value as { type: string; text?: string; model?: string; modelName?: string; contextUsage?: ContextUsage | null };
|
|
183
|
+
const event = value as { type: string; active?: boolean; text?: string; model?: string; modelName?: string; contextUsage?: ContextUsage | null };
|
|
164
184
|
const member = this.store.state.members[actor];
|
|
165
185
|
if (!member || !isLive(member) || (member.status === "stopping" && event.type !== "model_error")) return;
|
|
166
186
|
this.store.update(next => {
|
|
167
187
|
const current = next.members[actor];
|
|
168
|
-
if (event.type === "
|
|
188
|
+
if (event.type === "blocked") { if (event.active) current.needsInput = true; else delete current.needsInput; }
|
|
189
|
+
else if (event.type === "telemetry") {
|
|
169
190
|
current.model = event.model ?? current.model;
|
|
170
191
|
current.modelName = event.modelName;
|
|
171
192
|
current.contextUsage = event.contextUsage ?? undefined;
|
|
172
193
|
}
|
|
173
194
|
else if (event.type === "agent_start") { current.status = "busy"; current.activity = "Working"; delete current.error; delete current.summary; }
|
|
174
|
-
else if (event.type === "agent_settled") { current.status = "idle"; current.activity = current.error ? "Model request failed" : current.summary || "Available for follow-up"; }
|
|
195
|
+
else if (event.type === "agent_settled") { delete current.needsInput; current.status = "idle"; current.activity = current.error ? "Model request failed" : current.summary || "Available for follow-up"; }
|
|
175
196
|
else if (event.type === "activity") current.activity = event.text;
|
|
176
197
|
else if (event.type === "summary") current.summary = event.text?.slice(0, 500);
|
|
177
198
|
else if (event.type === "model_error") current.error = event.text || "Model request failed";
|
|
178
199
|
});
|
|
200
|
+
this.publishStatus();
|
|
179
201
|
if (event.type === "model_error" && actor !== "root" && member.status !== "stopping") {
|
|
180
202
|
const recipient = member.parent && isLive(this.store.state.members[member.parent]) ? member.parent : "root";
|
|
181
203
|
this.store.message(actor, { action: "send", recipients: [recipient], text: `Member ${actor} model error: ${event.text || "Model request failed"}` });
|
|
@@ -307,6 +329,7 @@ export class Coordinator {
|
|
|
307
329
|
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 };
|
|
308
330
|
// Reserve synchronously before the first await so concurrent launches cannot oversubscribe.
|
|
309
331
|
this.store.update(next => { next.members[id] = member; });
|
|
332
|
+
this.publishStatus();
|
|
310
333
|
member.model = args.model; member.thinking = args.thinking ?? "off";
|
|
311
334
|
member.terminal = this.options.interactive === true;
|
|
312
335
|
this.store.update(next => { next.members[id] = member; });
|
|
@@ -330,7 +353,7 @@ export class Coordinator {
|
|
|
330
353
|
chmodSync(join(this.store.dir, "sessions"), 0o700);
|
|
331
354
|
const rpcArgs = [...(this.options.interactive ? [] : ["--mode", "rpc"]), "--session-dir", join(this.store.dir, "sessions"), "-e", this.options.extension, "--provider", args.model.slice(0, slash), "--model", args.model.slice(slash + 1), "--thinking", args.thinking ?? "off"];
|
|
332
355
|
if (resumed?.sessionFile) rpcArgs.push("--session", resumed.sessionFile);
|
|
333
|
-
const spawnOptions = { cwd: member.cwd, args: rpcArgs, command: this.options.command, env: { ...process.env, AC_TEAMS_SOCKET: this.socketPath, AC_TEAMS_MEMBER: id, AC_TEAMS_TOKEN: member.token, AC_TEAMS_TERMINAL_SOCKET: join(this.store.dir, "t.sock") } };
|
|
356
|
+
const spawnOptions = { cwd: member.cwd, args: rpcArgs, command: this.options.command, env: { ...process.env, HERDR_ENV: "0", AC_TEAMS_SOCKET: this.socketPath, AC_TEAMS_MEMBER: id, AC_TEAMS_TOKEN: member.token, AC_TEAMS_TERMINAL_SOCKET: join(this.store.dir, "t.sock") } };
|
|
334
357
|
agent = this.options.interactive
|
|
335
358
|
? new TerminalAgent({ ...spawnOptions, id, dir: this.store.dir, request: (type, fields, timeout) => this.control(id, type, fields, timeout) })
|
|
336
359
|
: new RpcAgent(spawnOptions);
|
|
@@ -349,7 +372,8 @@ export class Coordinator {
|
|
|
349
372
|
agent.once("exit", () => {
|
|
350
373
|
this.agents.delete(id);
|
|
351
374
|
if (this.closed) return;
|
|
352
|
-
this.store.update(next => { next.members[id].status = next.members[id].status === "stopping" ? "stopped" : "interrupted"; });
|
|
375
|
+
this.store.update(next => { next.members[id].status = next.members[id].status === "stopping" ? "stopped" : "interrupted"; delete next.members[id].needsInput; });
|
|
376
|
+
this.publishStatus();
|
|
353
377
|
void this.stopDescendants(id).catch(error => this.options.warning(String(error)));
|
|
354
378
|
});
|
|
355
379
|
if (agent instanceof TerminalAgent) await agent.start();
|
|
@@ -363,12 +387,14 @@ export class Coordinator {
|
|
|
363
387
|
if (!isRecord(rpcState) || !isRecord(rpcState.model) || rpcState.model.provider !== args.model.slice(0, slash) || rpcState.model.id !== args.model.slice(slash + 1)) throw new Error("Pi did not select the requested model");
|
|
364
388
|
if (rpcState.model.name !== undefined || rpcState.contextUsage !== undefined) this.recordEvent(id, { type: "telemetry", model: args.model, modelName: rpcState.model.name, contextUsage: rpcState.contextUsage ?? null });
|
|
365
389
|
this.store.update(next => { if (next.members[id].status === "starting") next.members[id].status = prompt ? "busy" : "idle"; });
|
|
390
|
+
this.publishStatus();
|
|
366
391
|
if (prompt) await agent.request("prompt", { message: args.brief, streamingBehavior: "followUp" });
|
|
367
392
|
return this.publicMember(this.store.state.members[id]);
|
|
368
393
|
} catch (error) {
|
|
369
394
|
clearTimeout(timer); this.ready.delete(id);
|
|
370
395
|
if (agent) await agent.stop();
|
|
371
|
-
this.store.update(next => { next.members[id].status = "interrupted"; next.members[id].error = error instanceof Error ? error.message : "Startup failed"; });
|
|
396
|
+
this.store.update(next => { next.members[id].status = "interrupted"; delete next.members[id].needsInput; next.members[id].error = error instanceof Error ? error.message : "Startup failed"; });
|
|
397
|
+
this.publishStatus();
|
|
372
398
|
throw error;
|
|
373
399
|
}
|
|
374
400
|
}
|
|
@@ -379,7 +405,8 @@ export class Coordinator {
|
|
|
379
405
|
private async stopOne(id: string): Promise<void> {
|
|
380
406
|
const member = this.store.state.members[id];
|
|
381
407
|
if (!isLive(member)) return;
|
|
382
|
-
this.store.update(next => { next.members[id].status = "stopping"; });
|
|
408
|
+
this.store.update(next => { next.members[id].status = "stopping"; delete next.members[id].needsInput; });
|
|
409
|
+
this.publishStatus();
|
|
383
410
|
const agent = this.agents.get(id);
|
|
384
411
|
if (agent) await agent.stop();
|
|
385
412
|
else if (member.pid && processAlive(member.pid, member.identity)) throw new Error("No owned transport for live process; refusing to kill by PID");
|
package/src/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { showTeamsPanel } from "./panel.ts";
|
|
|
10
10
|
import { teamToolRenderers, renderTeamMessage, cleanText } from "./render.ts";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { Coordinator, assistantEvent, taskRequestGuidance } from "./coordinator.ts";
|
|
13
|
-
import { Connection } from "./connection.ts";
|
|
13
|
+
import { Connection, isRecord } from "./connection.ts";
|
|
14
14
|
import { policy, readDisplaySettings, defaultDisplaySettings } from "./config.ts";
|
|
15
15
|
|
|
16
16
|
export default function teams(pi: ExtensionAPI) {
|
|
@@ -27,6 +27,7 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
27
27
|
let exiting = false;
|
|
28
28
|
let disposeUI: (() => void) | undefined;
|
|
29
29
|
let telemetryTimer: NodeJS.Timeout | undefined;
|
|
30
|
+
let inputCount = 0;
|
|
30
31
|
const drafts = new Map<string, string>();
|
|
31
32
|
const display = { ...defaultDisplaySettings };
|
|
32
33
|
const self = environment[1] ?? "root";
|
|
@@ -44,8 +45,14 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
44
45
|
id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, sessionFile: ctx.sessionManager.getSessionFile(),
|
|
45
46
|
...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 }),
|
|
46
47
|
extension: fileURLToPath(import.meta.url), notice: notify, warning: warn,
|
|
48
|
+
status: (kind, active) => pi.events.emit(`herdr:${kind}`, { active, source: "ac-teams" }),
|
|
47
49
|
interactive: ctx.mode === "tui", model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined, thinking: pi.getThinkingLevel(),
|
|
48
|
-
}).then(value => {
|
|
50
|
+
}).then(value => {
|
|
51
|
+
coordinator = value;
|
|
52
|
+
if (ctx.isIdle?.() === false) value.recordEvent("root", { type: "agent_start" });
|
|
53
|
+
if (inputCount > 0) value.recordEvent("root", { type: "blocked", active: true });
|
|
54
|
+
return value;
|
|
55
|
+
}, error => { opening = undefined; throw error; });
|
|
49
56
|
return opening;
|
|
50
57
|
}
|
|
51
58
|
async function handle(operation: "agent" | "task" | "message", value: unknown, ctx: ExtensionContext): Promise<unknown> {
|
|
@@ -71,7 +78,7 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
71
78
|
return (await root(ctx)).handle("root", operation, args, authorized);
|
|
72
79
|
}
|
|
73
80
|
async function snapshot(ctx: ExtensionContext): Promise<TeamSnapshot> {
|
|
74
|
-
if (!child && !opening) return { members: [{ id: "root", cwd: ctx.cwd, depth: 0, status: ctx.isIdle() ? "idle" : "busy", notified: 0 }], tasks: [], messages: [], limit: 10, depth: 2 };
|
|
81
|
+
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 };
|
|
75
82
|
const status = await handle("agent", { action: "list" }, ctx) as Pick<TeamSnapshot, "members" | "limit" | "depth"> & { lastMessage: number };
|
|
76
83
|
const tasks: TaskView[] = [];
|
|
77
84
|
let total = 0;
|
|
@@ -106,7 +113,7 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
106
113
|
};
|
|
107
114
|
return api;
|
|
108
115
|
}
|
|
109
|
-
function report(ctx: ExtensionContext, event: { type: string; text?: string }) {
|
|
116
|
+
function report(ctx: ExtensionContext, event: { type: string; text?: string; active?: boolean }) {
|
|
110
117
|
if (exiting) return;
|
|
111
118
|
if (child && ctx.mode === "tui") void connection?.request("event", event).catch(error => warn(cleanText(String(error))));
|
|
112
119
|
else if (!child) coordinator?.recordEvent("root", event);
|
|
@@ -130,8 +137,18 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
130
137
|
telemetryTimer.unref();
|
|
131
138
|
void publish();
|
|
132
139
|
}
|
|
140
|
+
pi.events.on("herdr:blocked", (data: unknown) => {
|
|
141
|
+
// Main already receives this signal locally; relay only across the team socket.
|
|
142
|
+
if (!isRecord(data) || typeof data.active !== "boolean" || data.source === "ac-teams") return;
|
|
143
|
+
inputCount = Math.max(0, inputCount + (data.active ? 1 : -1));
|
|
144
|
+
if (context) {
|
|
145
|
+
const event = { type: "blocked", active: inputCount > 0 };
|
|
146
|
+
if (child) void connection?.request("event", event).catch(error => warn(cleanText(String(error))));
|
|
147
|
+
else coordinator?.recordEvent("root", event);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
133
150
|
pi.on("agent_start", (_event, ctx) => report(ctx, { type: "agent_start" }));
|
|
134
|
-
pi.on("agent_settled", (_event, ctx) => report(ctx, { type: "agent_settled" }));
|
|
151
|
+
pi.on("agent_settled", (_event, ctx) => { inputCount = 0; report(ctx, { type: "agent_settled" }); });
|
|
135
152
|
pi.on("tool_execution_start", (event, ctx) => report(ctx, { type: "activity", text: `Running ${event.toolName}`.slice(0, 500) }));
|
|
136
153
|
pi.on("message_end", (event, ctx) => {
|
|
137
154
|
const change = assistantEvent(event.message);
|
|
@@ -169,7 +186,7 @@ export default function teams(pi: ExtensionAPI) {
|
|
|
169
186
|
}
|
|
170
187
|
pi.on("session_start", async (_event, ctx) => {
|
|
171
188
|
if (!child && coordinator && coordinator.store.state.id !== ctx.sessionManager.getSessionId()) { await close(); drafts.clear(); }
|
|
172
|
-
context = ctx; exiting = false;
|
|
189
|
+
context = ctx; exiting = false; inputCount = 0;
|
|
173
190
|
if (child) {
|
|
174
191
|
if (environment.some(value => !value)) throw new Error("Incomplete AC_TEAMS membership environment; refusing independent-root fallback");
|
|
175
192
|
connection = new Connection(environment[0]!, environment[1]!, environment[2]!);
|
package/src/panel.ts
CHANGED
|
@@ -184,8 +184,10 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
|
|
|
184
184
|
if (graph && !graphData && tasks.length) left[0] = fg("muted", "Graph unavailable or too large; showing list.");
|
|
185
185
|
} else if (tab === 1) {
|
|
186
186
|
left = [fg("muted", " Member Session"), "", ...memberIds().map((id, index) => {
|
|
187
|
-
const
|
|
188
|
-
|
|
187
|
+
const member = snapshot.members.find(member => member.id === id);
|
|
188
|
+
const description = id === "root" ? "Main conversation" : member?.status ?? "Unavailable";
|
|
189
|
+
return fg(index === memberIndex ? "accent" : "text", `${index === memberIndex ? "●" : " "} ${name(id).padEnd(12)} `)
|
|
190
|
+
+ (member?.needsInput ? fg("error", "Needs input") : fg(index === memberIndex ? "accent" : "text", clean(description)));
|
|
189
191
|
})];
|
|
190
192
|
selectedRow = memberIndex + 2;
|
|
191
193
|
right = split ? memberDetails(rightWidth) : [];
|
|
@@ -224,7 +226,7 @@ export async function showTeamsPanel(ctx: ExtensionContext, host: PanelHost): Pr
|
|
|
224
226
|
const id = memberIds()[memberIndex];
|
|
225
227
|
const member = snapshot.members.find(member => member.id === id);
|
|
226
228
|
const assigned = tasks.filter(task => task.owner === id);
|
|
227
|
-
return wrap([heading(name(id)), "", label("State", clean(member?.status ?? (id === "root" ? "Main conversation" : "Unavailable"))),
|
|
229
|
+
return wrap([heading(name(id)), "", label("State", member?.needsInput ? fg("error", "Needs input") : clean(member?.status ?? (id === "root" ? "Main conversation" : "Unavailable"))),
|
|
228
230
|
label("ID", id), label("Model / context", clean(modelContext(member ?? {}))),
|
|
229
231
|
label("Cwd", clean(member?.cwd ?? (id === host.self ? ctx.cwd : "Unavailable"))),
|
|
230
232
|
label("Session", clean(member?.sessionFile ?? member?.sessionId ?? "Unavailable")),
|
package/src/schemas.ts
CHANGED
|
@@ -40,13 +40,15 @@ const ContextUsageSchema = Type.Object({
|
|
|
40
40
|
contextWindow: Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }),
|
|
41
41
|
percent: Type.Union([Type.Null(), Type.Number({ minimum: 0 })]),
|
|
42
42
|
}, { additionalProperties: false });
|
|
43
|
-
const EventSchema = Type.Object({
|
|
43
|
+
const EventSchema = Type.Union([Type.Object({
|
|
44
|
+
type: Type.Literal("blocked"), active: Type.Boolean(),
|
|
45
|
+
}, { additionalProperties: false }), Type.Object({
|
|
44
46
|
type: Type.Union(["agent_start", "agent_settled", "activity", "summary", "model_error", "telemetry"].map(v => Type.Literal(v))),
|
|
45
47
|
text: Type.Optional(Type.String({ maxLength: 2000 })),
|
|
46
48
|
model: Type.Optional(Type.String({ maxLength: 256 })),
|
|
47
49
|
modelName: Type.Optional(Type.String({ maxLength: 256 })),
|
|
48
50
|
contextUsage: Type.Optional(Type.Union([Type.Null(), ContextUsageSchema])),
|
|
49
|
-
}, { additionalProperties: false });
|
|
51
|
+
}, { additionalProperties: false })]);
|
|
50
52
|
export function validate(operation: string, value: unknown): void {
|
|
51
53
|
const schema = operation === "agent" ? AgentSchema : operation === "task" ? TaskSchema : operation === "message" ? MessageSchema : operation === "ui" ? UserActionSchema : operation === "event" ? EventSchema : undefined;
|
|
52
54
|
if (!schema || !Value.Check(schema, value)) throw new Error(`Invalid ${operation} request`);
|
package/src/store.ts
CHANGED
|
@@ -28,7 +28,9 @@ export function processIdentity(pid: number): string | undefined {
|
|
|
28
28
|
}
|
|
29
29
|
throw error;
|
|
30
30
|
}
|
|
31
|
-
const
|
|
31
|
+
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
32
|
+
if (fields[0] === "Z" || fields[0] === "X") return undefined;
|
|
33
|
+
const start = fields[19];
|
|
32
34
|
requireValue(start && /^\d+$/.test(start), "Cannot determine process identity");
|
|
33
35
|
const boot = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
|
|
34
36
|
requireValue(boot, "Cannot determine boot identity");
|
|
@@ -39,9 +41,8 @@ export function processAlive(pid: number, identity?: string): boolean {
|
|
|
39
41
|
requireValue(integer(pid, 1), "Invalid PID");
|
|
40
42
|
try { process.kill(pid, 0); }
|
|
41
43
|
catch (error) { if (code(error) === "ESRCH") return false; throw error; }
|
|
42
|
-
if (identity === undefined) return true;
|
|
43
44
|
const actual = processIdentity(pid);
|
|
44
|
-
return actual !== undefined && actual === identity;
|
|
45
|
+
return actual !== undefined && (identity === undefined || actual === identity);
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
function validateWorktree(value: unknown): value is Worktree {
|
|
@@ -90,6 +91,7 @@ function validate(state: unknown): asserts state is TeamState {
|
|
|
90
91
|
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");
|
|
91
92
|
}
|
|
92
93
|
requireValue(member.terminal === undefined || typeof member.terminal === "boolean", "Invalid member terminal flag");
|
|
94
|
+
requireValue(member.needsInput === undefined || typeof member.needsInput === "boolean", "Invalid member input flag");
|
|
93
95
|
requireValue(member.pid === undefined || integer(member.pid, 1), "Invalid member PID");
|
|
94
96
|
requireValue(member.worktree === undefined || validateWorktree(member.worktree), "Invalid member worktree");
|
|
95
97
|
const seen = new Set<string>([key]);
|
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_") && value !== undefined)
|
|
47
|
+
.filter(([key, value]) => (key.startsWith("AC_TEAMS_") || key === "HERDR_ENV") && 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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn, execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
3
|
import { CustomEditor, getSettingsListTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { matchesKey, renderFakeCursor, truncateToWidth, visibleWidth, SettingsList } from "@earendil-works/pi-tui";
|
|
4
|
+
import { matchesKey, renderFakeCursor, truncateToWidth, visibleWidth, SettingsList, type EditorComponent, type Focusable } from "@earendil-works/pi-tui";
|
|
5
5
|
import { cleanText } from "./render.ts";
|
|
6
6
|
import { isLive, taskName, memberName } from "./types.ts";
|
|
7
7
|
import { saveDisplaySettings, type DisplaySettings } from "./config.ts";
|
|
@@ -68,11 +68,11 @@ export async function showDisplayConfig(ctx: ExtensionContext, settings: Display
|
|
|
68
68
|
}, { overlay: true, overlayOptions: { width: 64, minWidth: 30, anchor: "center" } });
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
export function installTeamFooter(
|
|
71
|
+
export function installTeamFooter(_pi: ExtensionAPI, ctx: ExtensionContext, host: PanelHost, display: DisplaySettings): () => void {
|
|
72
72
|
let members: Awaited<ReturnType<PanelHost["snapshot"]>>["members"] = [];
|
|
73
73
|
let tasks: Awaited<ReturnType<PanelHost["snapshot"]>>["tasks"] = [];
|
|
74
74
|
let selected: string | undefined, start = 0;
|
|
75
|
-
let editor:
|
|
75
|
+
let editor: (EditorComponent & Partial<Focusable> & { isShowingAutocomplete?(): boolean }) | undefined;
|
|
76
76
|
let redraw = () => {};
|
|
77
77
|
let disposed = false;
|
|
78
78
|
let timer: NodeJS.Timeout | undefined;
|
|
@@ -87,10 +87,19 @@ export function installTeamFooter(pi: ExtensionAPI, ctx: ExtensionContext, host:
|
|
|
87
87
|
};
|
|
88
88
|
async function refresh() {
|
|
89
89
|
try {
|
|
90
|
-
if (!display.tasks && !display.agents)
|
|
90
|
+
if (!display.tasks && !display.agents) {
|
|
91
|
+
ctx.ui.setStatus("ac-teams", undefined);
|
|
92
|
+
redraw();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
91
95
|
const next = await host.snapshot();
|
|
92
96
|
if (disposed) return;
|
|
93
97
|
members = next.members; tasks = next.tasks;
|
|
98
|
+
const count = ids().length;
|
|
99
|
+
ctx.ui.setStatus("ac-teams", `Teams ${[
|
|
100
|
+
...(display.agents ? [`${count} ${count === 1 ? "agent" : "agents"}`, `${members.filter(member => member.status === "busy").length} working`] : []),
|
|
101
|
+
...(display.tasks ? [`${tasks.filter(task => task.status === "done").length}/${tasks.length} tasks done`] : []),
|
|
102
|
+
].join(" · ")}`);
|
|
94
103
|
if (selected && !ids().includes(selected)) { selected = undefined; if (editor) editor.focused = true; }
|
|
95
104
|
start = Math.min(start, Math.max(0, ids().length - 1 - slots()));
|
|
96
105
|
reveal(); redraw();
|
|
@@ -102,61 +111,69 @@ export function installTeamFooter(pi: ExtensionAPI, ctx: ExtensionContext, host:
|
|
|
102
111
|
render: width => display.tasks ? renderTaskSummary(tasks, theme, width) : [],
|
|
103
112
|
invalidate() {},
|
|
104
113
|
}), { placement: "aboveEditor" });
|
|
105
|
-
ctx.ui.
|
|
114
|
+
ctx.ui.setWidget("ac-teams-agents", (tui, theme) => {
|
|
106
115
|
redraw = () => tui.requestRender();
|
|
107
|
-
const unsubscribe = data.onBranchChange(redraw);
|
|
108
116
|
return {
|
|
109
117
|
render(width) {
|
|
110
118
|
if (!display.agents && selected !== undefined) { selected = undefined; if (editor) editor.focused = true; }
|
|
111
|
-
const branch = data.getGitBranch(), usage = ctx.getContextUsage()?.percent;
|
|
112
119
|
const row = (id: string) => {
|
|
113
120
|
const member = members.find(member => member.id === id);
|
|
114
121
|
const assigned = tasks.find(task => task.owner === id && task.status === "in_progress") ?? tasks.find(task => task.owner === id && task.status !== "done");
|
|
115
122
|
const status = member?.status === "busy" ? "working" : member?.status ?? "idle";
|
|
116
123
|
const metrics = id === host.self ? readTelemetry(ctx) : member ?? {};
|
|
117
124
|
const title = id === "root" ? "main" : memberName(member ?? { id });
|
|
118
|
-
const
|
|
119
|
-
|
|
125
|
+
const color = selected === id || host.self === id ? "accent" : "muted";
|
|
126
|
+
const titleRow = theme.fg(color, `${selected === id ? ">" : " "} ${host.self === id ? "●" : "○"} ${cleanText(title).replaceAll("\n", " ")}`);
|
|
127
|
+
const attention = member?.needsInput ? theme.fg("error", " · Needs input") : "";
|
|
128
|
+
const description = ` ${id === "root" ? "" : status + " · "}${modelContext(metrics)}${assigned ? ` · ${taskName(assigned)}` : ""}${member?.error || member?.activity ? ` · ${member.error || member.activity}` : ""}`;
|
|
129
|
+
return titleRow + attention + theme.fg(color, cleanText(description).replaceAll("\n", " "));
|
|
120
130
|
};
|
|
121
131
|
const peers = ids().slice(1);
|
|
122
|
-
return [
|
|
123
|
-
|
|
124
|
-
...
|
|
125
|
-
...(display.agents ? ["", row("root"), ...peers.slice(start, start + slots()).map(row),
|
|
132
|
+
return [
|
|
133
|
+
...(host.self !== "root" ? [theme.fg("dim", "Ctrl+Shift+Left: Main (keeps questions open)")] : []),
|
|
134
|
+
...(display.agents ? [row("root"), ...peers.slice(start, start + slots()).map(row),
|
|
126
135
|
...(peers.length > 4 ? [theme.fg("dim", ` ↑ ${start} above · ↓ ${Math.max(0, peers.length - start - slots())} below`)] : [])] : []),
|
|
127
136
|
].map(line => truncateToWidth(line, width));
|
|
128
137
|
},
|
|
129
138
|
invalidate() {},
|
|
130
|
-
dispose() { disposed = true; clearTimeout(timer); unsubscribe(); },
|
|
131
139
|
};
|
|
132
|
-
});
|
|
133
|
-
ctx.ui.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
140
|
+
}, { placement: "belowEditor" });
|
|
141
|
+
const previousEditor = ctx.ui.getEditorComponent();
|
|
142
|
+
const teamEditor: NonNullable<typeof previousEditor> = (tui, theme, keys) => {
|
|
143
|
+
const current: NonNullable<typeof editor> = previousEditor?.(tui, theme, keys) ?? new CustomEditor(tui, theme, keys);
|
|
144
|
+
// Decorate the instance so custom editor methods and Pi's app-control wiring survive.
|
|
145
|
+
const render = current.render.bind(current), handleInput = current.handleInput.bind(current);
|
|
146
|
+
current.render = (width: number) => {
|
|
147
|
+
const lines = render(width);
|
|
148
|
+
return disposed || selected === undefined ? lines : lines.map(line => line.replace(renderFakeCursor(" "), " "));
|
|
149
|
+
};
|
|
150
|
+
current.handleInput = (input: string) => {
|
|
151
|
+
if (disposed) { handleInput(input); return; }
|
|
152
|
+
if (!display.agents) { selected = undefined; current.focused = true; handleInput(input); return; }
|
|
153
|
+
if (selected !== undefined) {
|
|
154
|
+
const destinations = ids(), index = destinations.indexOf(selected);
|
|
155
|
+
if (matchesKey(input, "down")) selected = destinations[Math.min(destinations.length - 1, index + 1)];
|
|
156
|
+
else if (matchesKey(input, "up")) selected = index <= 0 ? undefined : destinations[index - 1];
|
|
157
|
+
else if (matchesKey(input, "escape")) selected = undefined;
|
|
158
|
+
else if (matchesKey(input, "enter")) {
|
|
159
|
+
const id = selected; selected = undefined;
|
|
160
|
+
void host.visit(id).catch(error => ctx.ui.notify(cleanText(String(error)), "error"));
|
|
161
|
+
} else { selected = undefined; handleInput(input); }
|
|
162
|
+
current.focused = selected === undefined; reveal(); redraw(); return;
|
|
138
163
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (selected !== undefined) {
|
|
142
|
-
const destinations = ids(), index = destinations.indexOf(selected);
|
|
143
|
-
if (matchesKey(input, "down")) selected = destinations[Math.min(destinations.length - 1, index + 1)];
|
|
144
|
-
else if (matchesKey(input, "up")) selected = index <= 0 ? undefined : destinations[index - 1];
|
|
145
|
-
else if (matchesKey(input, "escape")) selected = undefined;
|
|
146
|
-
else if (matchesKey(input, "enter")) {
|
|
147
|
-
const id = selected; selected = undefined;
|
|
148
|
-
void host.visit(id).catch(error => ctx.ui.notify(cleanText(String(error)), "error"));
|
|
149
|
-
} else { selected = undefined; super.handleInput(input); }
|
|
150
|
-
this.focused = selected === undefined; reveal(); redraw(); return;
|
|
151
|
-
}
|
|
152
|
-
if (matchesKey(input, "down") && !this.getText() && !this.isShowingAutocomplete()) {
|
|
153
|
-
selected = "root"; start = 0; this.focused = false; redraw(); return;
|
|
154
|
-
}
|
|
155
|
-
super.handleInput(input);
|
|
164
|
+
if (matchesKey(input, "down") && !current.getText() && !current.isShowingAutocomplete?.()) {
|
|
165
|
+
selected = "root"; start = 0; current.focused = false; redraw(); return;
|
|
156
166
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
167
|
+
handleInput(input);
|
|
168
|
+
};
|
|
169
|
+
editor = current;
|
|
170
|
+
return current;
|
|
171
|
+
};
|
|
172
|
+
ctx.ui.setEditorComponent(teamEditor);
|
|
173
|
+
return () => {
|
|
174
|
+
disposed = true; clearTimeout(timer);
|
|
175
|
+
ctx.ui.setWidget("ac-teams-tasks", undefined); ctx.ui.setWidget("ac-teams-agents", undefined);
|
|
176
|
+
ctx.ui.setStatus("ac-teams", undefined);
|
|
177
|
+
if (ctx.ui.getEditorComponent() === teamEditor) ctx.ui.setEditorComponent(previousEditor);
|
|
178
|
+
};
|
|
162
179
|
}
|
package/src/types.ts
CHANGED