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 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.2
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
- Team tool results and messages are compact by default. Ctrl+O expands them into labeled fields. UI state colors do not replace the underlying progress, ownership, or dependency fields.
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",
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"
@@ -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)) if (member.id !== "root" && isLive(member)) member.status = "interrupted";
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 === "telemetry") {
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 => { coordinator = value; return value; }, error => { opening = undefined; throw error; });
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 description = id === "root" ? "Main conversation" : snapshot.members.find(member => member.id === id)?.status ?? "Unavailable";
188
- return fg(index === memberIndex ? "accent" : "text", `${index === memberIndex ? "●" : " "} ${name(id).padEnd(12)} ${clean(description)}`);
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 { MessageRenderer, Theme, ToolRenderers } from "@earendil-works/pi-coding-agent";
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
- return entry(name, args, context.expanded, theme);
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
- return entry(`${name} ${options.isPartial ? "receiving" : "result"}`, data, options.expanded, theme, context.isError);
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 start = stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19];
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]);
@@ -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,
@@ -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 description = `${title} ${id === "root" ? "" : status + " · "}${modelContext(metrics)}${assigned ? ` · ${taskName(assigned)}` : ""}${member?.error || member?.activity ? ` · ${member.error || member.activity}` : ""}`;
126
- return theme.fg(selected === id || host.self === id ? "accent" : "muted", `${selected === id ? ">" : " "} ${host.self === id ? "●" : "○"} ${cleanText(description).replaceAll("\n", " ")}`);
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 (display.agents ? [row("root"), ...peers.slice(start, start + slots()).map(row),
130
- ...(peers.length > 4 ? [theme.fg("dim", ` ↑ ${start} above · ↓ ${Math.max(0, peers.length - start - slots())} below`)] : [])] : []
131
- ).map(line => truncateToWidth(line, width));
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
@@ -38,6 +38,7 @@ export interface Member {
38
38
  activity?: string;
39
39
  summary?: string;
40
40
  terminal?: boolean;
41
+ needsInput?: boolean;
41
42
  }
42
43
  export const memberNameMaxLength = 40;
43
44
  export const memberNamePattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)?(?![\s\S])/;