ac-teams 0.0.3 → 0.0.5
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 +2 -2
- package/docs/usage.md +9 -1
- package/package.json +3 -1
- package/src/coordinator.ts +35 -8
- package/src/index.ts +23 -6
- package/src/panel.ts +5 -3
- package/src/render.ts +65 -5
- package/src/schemas.ts +4 -2
- package/src/store.ts +5 -3
- package/src/terminal-agent.ts +1 -1
- package/src/terminal-ui.ts +10 -5
- 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.
|
|
@@ -28,7 +28,7 @@ Your Pi session is the root; its child agents are members. Ask Pi to delegate a
|
|
|
28
28
|
|
|
29
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 row, then Enter to open a member. Ctrl+Shift+Left returns to Main without stopping the member. `/teams stop` stops every descendant.
|
|
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
|
@@ -14,6 +14,10 @@ In interactive Pi, `/teams` or `/teams panel` opens the team panel. `/teams team
|
|
|
14
14
|
|
|
15
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
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.
|
|
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
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.
|
|
@@ -28,7 +32,11 @@ A task request is a proposal, not an instruction to create a task. Main receives
|
|
|
28
32
|
|
|
29
33
|
The send receipt confirms delivery, not acceptance or task creation. This is agent guidance, not an automatic semantic duplicate detector.
|
|
30
34
|
|
|
31
|
-
|
|
35
|
+
Each team tool call uses one line with a status dot, tool name, action, and result preview. Bold, colored badges such as `[todo]` separate statuses from titles and names.
|
|
36
|
+
|
|
37
|
+
The `+N lines` hint shows the hidden detail count. Ctrl+O expands the full request and response JSON; mouse-enabled Pi views also support clicking the row.
|
|
38
|
+
|
|
39
|
+
Team notification messages remain compact; Ctrl+O expands their labeled fields. UI state colors do not replace the underlying progress, ownership, or dependency fields.
|
|
32
40
|
|
|
33
41
|
## Show tasks and configure the displays
|
|
34
42
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ac-teams",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"description": "Small, persistent teams for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -15,10 +15,12 @@
|
|
|
15
15
|
"engines": { "node": ">=22.19.0" },
|
|
16
16
|
"peerDependencies": {
|
|
17
17
|
"@earendil-works/pi-coding-agent": "^1.4.0",
|
|
18
|
+
"@earendil-works/pi-tui": "^1.4.0",
|
|
18
19
|
"typebox": "^1.0.0"
|
|
19
20
|
},
|
|
20
21
|
"devDependencies": {
|
|
21
22
|
"@earendil-works/pi-coding-agent": "1.4.0",
|
|
23
|
+
"@earendil-works/pi-tui": "1.4.0",
|
|
22
24
|
"@types/node": "^22.0.0",
|
|
23
25
|
"typebox": "^1.0.0",
|
|
24
26
|
"typescript": "^5.9.0"
|
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/render.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { stripVTControlCharacters } from "node:util";
|
|
2
|
-
import type
|
|
3
|
-
import { truncateToWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
|
|
2
|
+
import { keyHint, type MessageRenderer, type Theme, type ToolRenderers } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Container, Text, TruncatedText, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
|
|
4
4
|
|
|
5
5
|
export const cleanText = (text: string): string => stripVTControlCharacters(text).replaceAll("\t", " ").replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
|
|
6
6
|
const inline = (value: unknown) => cleanText(String(value)).replace(/[\n\r\t]/g, " ");
|
|
@@ -46,18 +46,78 @@ function entry(title: string, value: unknown, expanded: boolean, theme: Theme, e
|
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
function preview(items: unknown[], count: string, theme: Theme): string {
|
|
50
|
+
const entries = items.slice(0, 3).map(item => toolSummary(item, theme)).filter(Boolean);
|
|
51
|
+
if (items.length > 3) entries.push(`+${items.length - 3} more`);
|
|
52
|
+
return [count, ...entries].join(" · ");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function toolSummary(data: unknown, theme: Theme): string {
|
|
56
|
+
if (Array.isArray(data)) return preview(data, `${data.length} messages`, theme);
|
|
57
|
+
if (data && typeof data === "object") {
|
|
58
|
+
const value = data as Record<string, unknown>;
|
|
59
|
+
if (Array.isArray(value.members)) return preview(value.members, `${value.members.length} members`, theme);
|
|
60
|
+
if (Array.isArray(value.tasks)) return preview(value.tasks, `${value.tasks.length} of ${value.total} tasks`, theme);
|
|
61
|
+
const label = value.title ?? value.name ?? value.brief ?? value.text ?? value.query ?? value.id;
|
|
62
|
+
const status = value.status === undefined ? "" : inline(value.status);
|
|
63
|
+
const color = status === "done" ? "success"
|
|
64
|
+
: status === "interrupted" ? "error"
|
|
65
|
+
: status === "busy" || status === "in_progress" ? "accent"
|
|
66
|
+
: status === "todo" || status === "starting" || status === "stopping" ? "warning" : "muted";
|
|
67
|
+
const parts = [status ? theme.fg(color, theme.bold(`[${status}]`)) : "", label === undefined ? "" : inline(label)];
|
|
68
|
+
if (Array.isArray(value.recipients)) parts.push(`to ${value.recipients.map(inline).join(", ")}`);
|
|
69
|
+
for (const key of ["seq", "limit", "depth"]) {
|
|
70
|
+
if (value[key] !== undefined) parts.push(`${key}: ${inline(value[key])}`);
|
|
71
|
+
}
|
|
72
|
+
return parts.filter(Boolean).join(" ");
|
|
73
|
+
}
|
|
74
|
+
return data === undefined || data === null ? "" : inline(data);
|
|
75
|
+
}
|
|
76
|
+
|
|
49
77
|
export function teamToolRenderers(name: string): ToolRenderers {
|
|
50
78
|
return {
|
|
79
|
+
renderShell: "self",
|
|
51
80
|
renderCall(args, theme, context) {
|
|
52
|
-
|
|
81
|
+
if (!context.isPartial) return new Container();
|
|
82
|
+
const { action, ...fields } = (args ?? {}) as Record<string, unknown>;
|
|
83
|
+
const request = toolSummary(fields, theme);
|
|
84
|
+
const text = `${theme.fg("warning", "●")} ${theme.fg("toolTitle", name)} · ${inline(action ?? "...")} → running${request ? ` · ${request}` : ""}`;
|
|
85
|
+
return new TruncatedText(text, context.outputPad, 0);
|
|
53
86
|
},
|
|
54
87
|
renderResult(result, options, theme, context) {
|
|
88
|
+
if (options.isPartial) return new Container();
|
|
89
|
+
const { action, ...fields } = (context.args ?? {}) as Record<string, unknown>;
|
|
90
|
+
const text = result.content.filter(block => block.type === "text").map(block => block.text).join("\n");
|
|
55
91
|
let data: unknown = result.details;
|
|
56
92
|
if (data === undefined) {
|
|
57
|
-
const text = result.content.filter(part => part.type === "text").map(part => part.text).join("\n");
|
|
58
93
|
try { data = JSON.parse(text); } catch { data = text; }
|
|
59
94
|
}
|
|
60
|
-
|
|
95
|
+
const outcome = context.isError ? `error · ${inline(text)}` : toolSummary(data, theme) || "done";
|
|
96
|
+
const request = toolSummary(fields, theme);
|
|
97
|
+
const heading = `${theme.fg(context.isError ? "error" : "success", "●")} ${theme.fg("toolTitle", name)} · ${inline(action ?? "...")} → ${outcome}${request && !outcome.includes(request) ? ` · ${request}` : ""}`;
|
|
98
|
+
const requestJSON = cleanText(JSON.stringify(context.args, null, 2));
|
|
99
|
+
const responseJSON = cleanText(result.details === undefined ? text : JSON.stringify(result.details, null, 2));
|
|
100
|
+
const hiddenLines = requestJSON.split("\n").length + responseJSON.split("\n").length + 2;
|
|
101
|
+
const hint = theme.fg("muted", options.expanded ? " (" : ` … +${hiddenLines} lines (`)
|
|
102
|
+
+ keyHint("app.tools.expand", options.expanded ? "to collapse" : "to expand") + theme.fg("muted", ")");
|
|
103
|
+
const row = new Container();
|
|
104
|
+
row.addChild({
|
|
105
|
+
invalidate() {},
|
|
106
|
+
render(width) {
|
|
107
|
+
const pad = " ".repeat(Math.min(context.outputPad, Math.floor(width / 2)));
|
|
108
|
+
const available = Math.max(0, width - pad.length * 2);
|
|
109
|
+
// Keep the hint visible unless the terminal is too narrow to also identify the tool.
|
|
110
|
+
const suffix = available >= visibleWidth(hint) + 30 ? hint : "";
|
|
111
|
+
return [pad + truncateToWidth(heading, available - visibleWidth(suffix)) + suffix + pad];
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
if (options.expanded) {
|
|
115
|
+
row.addChild(new Text(theme.fg("muted", "Request"), context.outputPad, 0));
|
|
116
|
+
row.addChild(new Text(requestJSON, context.outputPad, 0));
|
|
117
|
+
row.addChild(new Text(theme.fg("muted", "Response"), context.outputPad, 0));
|
|
118
|
+
row.addChild(new Text(responseJSON, context.outputPad, 0));
|
|
119
|
+
}
|
|
120
|
+
return row;
|
|
61
121
|
},
|
|
62
122
|
};
|
|
63
123
|
}
|
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
|
@@ -122,13 +122,18 @@ export function installTeamFooter(_pi: ExtensionAPI, ctx: ExtensionContext, host
|
|
|
122
122
|
const status = member?.status === "busy" ? "working" : member?.status ?? "idle";
|
|
123
123
|
const metrics = id === host.self ? readTelemetry(ctx) : member ?? {};
|
|
124
124
|
const title = id === "root" ? "main" : memberName(member ?? { id });
|
|
125
|
-
const
|
|
126
|
-
|
|
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", " "));
|
|
127
130
|
};
|
|
128
131
|
const peers = ids().slice(1);
|
|
129
|
-
return
|
|
130
|
-
...(
|
|
131
|
-
|
|
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),
|
|
135
|
+
...(peers.length > 4 ? [theme.fg("dim", ` ↑ ${start} above · ↓ ${Math.max(0, peers.length - start - slots())} below`)] : [])] : []),
|
|
136
|
+
].map(line => truncateToWidth(line, width));
|
|
132
137
|
},
|
|
133
138
|
invalidate() {},
|
|
134
139
|
};
|
package/src/types.ts
CHANGED