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 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.
@@ -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 status shows descriptive names, models, and context usage. `/teams config` toggles these displays independently.
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 footer, 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
@@ -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 Pi's status lines. 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.
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 footer. Up/Down select members; Enter opens the selected live session; Esc returns to editing.
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 stays below the status lines. It shows completed/total counts and up to five numbered task labels, ordered by:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ac-teams",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "Small, persistent teams for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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/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,
@@ -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(pi: ExtensionAPI, ctx: ExtensionContext, host: PanelHost, display: DisplaySettings): () => void {
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: CustomEditor | undefined;
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) return;
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.setFooter((tui, theme, data) => {
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 description = `${title} ${id === "root" ? "" : status + " · "}${modelContext(metrics)}${assigned ? ` · ${taskName(assigned)}` : ""}${member?.error || member?.activity ? ` · ${member.error || member.activity}` : ""}`;
119
- 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", " "));
120
130
  };
121
131
  const peers = ids().slice(1);
122
- return [theme.fg("dim", `${cleanText(ctx.cwd)}${branch ? ` (${cleanText(branch)})` : ""}`),
123
- theme.fg("dim", `${usage == null ? "Unknown" : usage.toFixed(1) + "%"} context · ${ctx.model?.id ?? "no model"} · ${pi.getThinkingLevel()}`),
124
- ...Array.from(data.getExtensionStatuses().values(), status => theme.fg("dim", cleanText(status))),
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.setEditorComponent((tui, theme, keys) => {
134
- class TeamEditor extends CustomEditor {
135
- render(width: number) {
136
- const lines = super.render(width);
137
- return selected === undefined ? lines : lines.map(line => line.replace(renderFakeCursor(" "), " "));
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
- handleInput(input: string) {
140
- if (!display.agents) { selected = undefined; this.focused = true; super.handleInput(input); return; }
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
- editor = new TeamEditor(tui, theme, keys);
159
- return editor;
160
- });
161
- return () => { disposed = true; clearTimeout(timer); ctx.ui.setWidget("ac-teams-tasks", undefined); ctx.ui.setFooter(undefined); ctx.ui.setEditorComponent(undefined); };
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
@@ -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])/;