ac-teams 0.0.12 → 0.0.14

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 and 1.5.0.
8
8
 
9
9
  ```sh
10
- pi install npm:ac-teams@0.0.4
10
+ pi install npm:ac-teams
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.
@@ -31,6 +31,30 @@ pi --teams-limit 4 --teams-depth 1
31
31
 
32
32
  Other delegation tools remain outside these limits. See [companion tools](usage.md#companion-tools-add-guidance-or-terminal-management).
33
33
 
34
+ ### Check headroom before adding concurrency
35
+
36
+ `team_agent list` and `limits` include a fresh `resources` advisory. Launch and teammate resume results include the sample taken before startup.
37
+
38
+ It reports host available RAM, occupied swap, recent memory stalls, and live/starting member counts. Values are bytes; Linux pressure averages are percentages over the last ten seconds. Missing or invalid metrics are `null` with a warning, not zero or an all-clear.
39
+
40
+ The check reads only `/proc/meminfo` and `/proc/pressure/memory` on demand. It adds no polling, process-tree scans, or subprocesses.
41
+
42
+ Available RAM below 10% triggers a low-headroom warning (a heuristic, not a per-member budget). Any nonzero ten-second memory-stall average also warns. Occupied swap alone does not trigger a pressure warning.
43
+
44
+ Starting members reserve count slots synchronously, but may not yet consume their full memory. Concurrent launch results warn about earlier starts.
45
+
46
+ Sequence launches and recheck `list` after startup before adding more. These advisories never block a launch, change the configured ceiling, reserve RAM, or stop a member. Host figures do not account for a tighter container/cgroup limit or guarantee future capacity; other teams and non-team processes also consume resources.
47
+
48
+ Idle members keep Pi and its extensions alive. Stop unneeded members to release their owned processes, then resume their saved sessions when needed.
49
+
50
+ This preserves transcripts, tasks, messages, and worktrees. Extension-owned language servers and scanners can dominate memory; ac-teams does not disable them or share them across sessions.
51
+
52
+ ### Avoid repeated work without dropping updates
53
+
54
+ The coordinator skips saves when member events leave the state unchanged or notification cursors do not advance. Real changes still use the same validated, atomic saves. Repeated model errors still deliver separate parent notifications.
55
+
56
+ The footer refreshes at the same interval without fetching message history. The panel still loads messages and complete tasks. Connected sessions cache display names until the snapshot file changes; they do not retain a second copy of the history.
57
+
34
58
  ## Display preferences are global
35
59
 
36
60
  `/teams config` saves task-summary and agent-selector visibility to `~/.pi/ac-teams/display.json`:
package/docs/usage.md CHANGED
@@ -39,7 +39,7 @@ Each team tool call uses one line with a status dot, tool name, action, and resu
39
39
 
40
40
  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.
41
41
 
42
- Team notification messages remain compact; Ctrl+O expands their labeled fields. UI state colors do not replace the underlying progress, ownership, or dependency fields.
42
+ Tool summaries and team notification previews prefer teammate names over IDs. Message receipt IDs stay out of compact summaries. Ctrl+O expands the original request and response, including IDs. Unknown members fall back to their IDs. UI state colors do not replace the underlying progress, ownership, or dependency fields.
43
43
 
44
44
  ## Main sessions share one team
45
45
 
@@ -159,7 +159,11 @@ Example `team_message` arguments:
159
159
  {"action":"send","recipients":["root"],"text":"Parser review complete. No edits; two malformed-input cases need tests."}
160
160
  ```
161
161
 
162
- Omit `recipients` to notify all other live members. Explicit sets must contain unique live member IDs; an invalid set rejects the whole send. All team members can read the full history, including direct messages.
162
+ Use exact teammate names (such as `"oom-review"`) or member IDs in `recipients`; IDs remain valid. Omit `recipients` to notify all other live members.
163
+
164
+ Each recipient must resolve to a different live member. An unknown name, a stopped member, or two entries for the same member rejects the whole send.
165
+
166
+ Receipts and stored history retain resolved IDs. All team members can read the full history, including direct messages.
163
167
 
164
168
  ```json
165
169
  {"action":"history","after":0,"limit":20}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ac-teams",
3
- "version": "0.0.12",
3
+ "version": "0.0.14",
4
4
  "description": "Small, persistent teams for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  import { createServer, type Server, type Socket } from "node:net";
2
2
  import { execFile } from "node:child_process";
3
- import { promisify } from "node:util";
3
+ import { isDeepStrictEqual, promisify } from "node:util";
4
4
  import { chmodSync, closeSync, constants, copyFileSync, existsSync, fstatSync, mkdirSync, openSync, lstatSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { homedir } from "node:os";
@@ -12,6 +12,7 @@ import { Egress, egressPort, providerRoutes, type Credentials } from "./egress.t
12
12
  import { Store, processAlive, processIdentity } from "./store.ts";
13
13
  import { stateRoot } from "./config.ts";
14
14
  import { RpcAgent } from "./rpc-agent.ts";
15
+ import { hostResources, type HostResources } from "./resources.ts";
15
16
  import { TerminalAgent, tmux } from "./terminal-agent.ts";
16
17
  import { authentic, isRecord, readLines, send } from "./connection.ts";
17
18
  import { validate } from "./schemas.ts";
@@ -52,6 +53,8 @@ export interface CoordinatorOptions {
52
53
  interactive?: boolean; model?: string; thinking?: string;
53
54
  /** Version of the running Pi; members use the image built for it. Defaults to the `pi` on PATH. */
54
55
  piVersion?: string;
56
+ /** Test hook: samples host headroom without depending on the test machine's load. */
57
+ resources?: () => HostResources;
55
58
  /** Test hook: provider token refresh interval (default one minute). */
56
59
  refreshMs?: number;
57
60
  /** Test hook: runs after a member container passes all checks and before it starts. */
@@ -270,21 +273,22 @@ export class Coordinator {
270
273
  const event = value as { type: string; active?: boolean; text?: string; model?: string; modelName?: string; contextUsage?: ContextUsage | null };
271
274
  const member = this.store.state.members[actor];
272
275
  if (!member || !isLive(member) || (member.status === "stopping" && event.type !== "model_error")) return;
273
- this.store.update(next => {
274
- const current = next.members[actor];
275
- if (event.type === "blocked") { if (event.active) current.needsInput = true; else delete current.needsInput; }
276
- else if (event.type === "telemetry") {
277
- current.model = event.model ?? current.model;
278
- current.modelName = event.modelName;
279
- current.contextUsage = event.contextUsage ?? undefined;
280
- }
281
- else if (event.type === "agent_start") { current.status = "busy"; current.activity = "Working"; delete current.error; delete current.summary; }
282
- 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"; }
283
- else if (event.type === "activity") current.activity = event.text;
284
- else if (event.type === "summary") current.summary = event.text?.slice(0, 500);
285
- else if (event.type === "model_error") current.error = event.text || "Model request failed";
286
- });
287
- this.publishStatus();
276
+ const current = { ...member };
277
+ if (event.type === "blocked") { if (event.active) current.needsInput = true; else delete current.needsInput; }
278
+ else if (event.type === "telemetry") {
279
+ current.model = event.model ?? current.model;
280
+ current.modelName = event.modelName;
281
+ current.contextUsage = event.contextUsage ?? undefined;
282
+ }
283
+ else if (event.type === "agent_start") { current.status = "busy"; current.activity = "Working"; delete current.error; delete current.summary; }
284
+ 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"; }
285
+ else if (event.type === "activity") current.activity = event.text;
286
+ else if (event.type === "summary") current.summary = event.text?.slice(0, 500);
287
+ else if (event.type === "model_error") current.error = event.text || "Model request failed";
288
+ if (!isDeepStrictEqual(member, current)) {
289
+ this.store.update(next => { next.members[actor] = structuredClone(current); });
290
+ this.publishStatus();
291
+ }
288
292
  if (event.type === "model_error" && !isMain(member) && member.status !== "stopping") {
289
293
  const recipient = member.parent && isLive(this.store.state.members[member.parent]) ? member.parent : this.self;
290
294
  this.store.message(actor, { action: "send", recipients: [recipient], text: `Member ${actor} model error: ${event.text || "Model request failed"}` });
@@ -347,10 +351,20 @@ export class Coordinator {
347
351
  }
348
352
  /** Live members this coordinator runs; main sessions run in their own terminals and never count. */
349
353
  live(): Member[] { return Object.values(this.store.state.members).filter(member => !isMain(member) && isLive(member)); }
354
+ private resources() {
355
+ const sample = (this.options.resources ?? hostResources)();
356
+ const live = this.live();
357
+ const startingMembers = live.filter(member => member.status === "starting").length;
358
+ const warnings = [...sample.warnings];
359
+ if (startingMembers) warnings.push(`${startingMembers} teammate(s) still starting; their full memory cost is not yet reflected. Sequence launches and recheck team_agent list after startup.`);
360
+ return { ...sample, liveMembers: live.length, startingMembers, warnings };
361
+ }
350
362
  ack(id: string, through: number): void {
351
363
  const last = this.store.state.messages.at(-1)?.seq ?? 0;
352
364
  if (!Number.isSafeInteger(through) || through < 0 || through > last) throw new Error("Invalid notification cursor");
353
- this.store.update(state => { state.members[id].notified = Math.max(state.members[id].notified, through); });
365
+ if (this.closed) throw new Error("Team is stopping");
366
+ if (through <= this.store.state.members[id].notified) return;
367
+ this.store.update(state => { state.members[id].notified = through; });
354
368
  }
355
369
  private notify(id: string): void {
356
370
  if (this.closed || this.notices.has(id)) return;
@@ -388,12 +402,12 @@ export class Coordinator {
388
402
  }
389
403
  private async agent(actor: string, args: AgentArgs, authorizedLimit: boolean): Promise<unknown> {
390
404
  const state = this.store.state;
391
- if (args.action === "list") return { team: state.id, limit: state.limit, depth: state.depth, sandbox: this.sandboxStatus(), lastMessage: state.messages.at(-1)?.seq ?? 0, members: Object.values(state.members).map(member => this.publicMember(member)) };
405
+ if (args.action === "list") return { team: state.id, limit: state.limit, depth: state.depth, sandbox: this.sandboxStatus(), resources: this.resources(), lastMessage: state.messages.at(-1)?.seq ?? 0, members: Object.values(state.members).map(member => this.publicMember(member)) };
392
406
  if (args.action === "limits") {
393
407
  if (!isMain(state.members[actor])) throw new Error("Only a main session can change team limits");
394
408
  if ((args.limit ?? state.limit) > 10 && !authorizedLimit) throw new Error("A ceiling above 10 requires explicit user confirmation or --teams-limit");
395
409
  this.store.update(next => { next.limit = args.limit ?? next.limit; next.depth = args.depth ?? next.depth; });
396
- return { limit: this.store.state.limit, depth: this.store.state.depth, sandbox: this.sandboxStatus() };
410
+ return { limit: this.store.state.limit, depth: this.store.state.depth, sandbox: this.sandboxStatus(), resources: this.resources() };
397
411
  }
398
412
  if (args.action === "launch" || args.action === "resume") {
399
413
  const launch = this.launch(actor, args);
@@ -455,6 +469,8 @@ export class Coordinator {
455
469
  const locked = resumed ? resumed.provider : parent.access?.network.allow ? parent.provider : undefined;
456
470
  if (access?.network.allow && locked && provider !== locked) throw new Error(`A member with restricted network access can only use ${locked} models`);
457
471
  const member: Member = resumed ? { ...resumed, token: randomUUID(), status: "starting", error: undefined, contextUsage: undefined, modelName: undefined, handover: undefined } : { id, name: args.name, role: "member", parent: actor, depth, token: randomUUID(), cwd, status: "starting", notified: 0, access, provider: access ? provider : undefined };
472
+ const resources = this.resources();
473
+ for (const warning of resources.warnings) this.options.warning(warning);
458
474
  // Reserve synchronously before the first await so concurrent launches cannot oversubscribe.
459
475
  this.store.update(next => { next.members[id] = member; });
460
476
  this.publishStatus();
@@ -531,7 +547,7 @@ export class Coordinator {
531
547
  this.store.update(next => { if (next.members[id].status === "starting") next.members[id].status = prompt ? "busy" : "idle"; });
532
548
  this.publishStatus();
533
549
  if (prompt) await agent.request("prompt", { message: args.brief, streamingBehavior: "followUp" });
534
- return this.publicMember(this.store.state.members[id]);
550
+ return { ...this.publicMember(this.store.state.members[id]), resources };
535
551
  } catch (error) {
536
552
  clearTimeout(timer); this.ready.delete(id);
537
553
  if (agent) await agent.stop();
package/src/index.ts CHANGED
@@ -7,13 +7,13 @@ import { installTeamFooter, visitMember, showDisplayConfig } from "./terminal-ui
7
7
  import { readTelemetry } from "./telemetry.ts";
8
8
  import { resolveTeammate, teamsCompletions } from "./commands.ts";
9
9
  import { showTeamsPanel } from "./panel.ts";
10
- import { teamToolRenderers, renderTeamMessage, cleanText } from "./render.ts";
11
- import { join } from "node:path";
10
+ import { teamToolRenderers, teamMessageRenderer, cleanText } from "./render.ts";
11
+ import { dirname, join } from "node:path";
12
12
  import { randomUUID } from "node:crypto";
13
13
  import { execFile } from "node:child_process";
14
14
  import { existsSync, readdirSync } from "node:fs";
15
15
  import { promisify } from "node:util";
16
- import { readState, teamsHome } from "./store.ts";
16
+ import { createMemberLabels, readState, teamsHome } from "./store.ts";
17
17
  import { teamEntryType, type TeamEntry } from "./mains.ts";
18
18
  import { isMain, memberName, taskName, type TeamState } from "./types.ts";
19
19
  import { Coordinator, assistantEvent, taskRequestGuidance } from "./coordinator.ts";
@@ -48,7 +48,14 @@ export default function teams(pi: ExtensionAPI) {
48
48
  const teamTools = ["team_agent", "team_task", "team_message"];
49
49
  const memberId = () => child ? environment[1]! : team?.member ?? "root";
50
50
  const teamId = (ctx: ExtensionContext) => team?.team ?? ctx.sessionManager.getSessionId();
51
- pi.registerMessageRenderer("ac-teams", renderTeamMessage);
51
+ const cachedLabels = createMemberLabels();
52
+ function memberLabels(): Record<string, string> {
53
+ try {
54
+ if (coordinator) return Object.fromEntries(Object.values(coordinator.store.state.members).map(member => [member.id, memberName(member)]));
55
+ return cachedLabels(child ? dirname(environment[0]!) : join(teamsHome(), teamId(context!)));
56
+ } catch { return { root: "Main" }; } // A transcript can render before its team is available.
57
+ }
58
+ pi.registerMessageRenderer("ac-teams", teamMessageRenderer(memberLabels));
52
59
  const warn = (message: string) => context?.ui.notify(message, "warning");
53
60
  const notify = (through: number, messages: Message[] = []) => {
54
61
  pi.sendMessage({ customType: "ac-teams", content: `New team messages through sequence ${through}. Read team_message history after your last cursor. Reply only with useful updates, not acknowledgements.`, display: true, details: { through, messages } }, { deliverAs: "followUp", triggerTurn: true });
@@ -190,7 +197,7 @@ export default function teams(pi: ExtensionAPI) {
190
197
  }
191
198
  return coordinator!.handle(coordinator!.self, operation, args, authorized);
192
199
  }
193
- async function snapshot(ctx: ExtensionContext): Promise<TeamSnapshot> {
200
+ async function snapshot(ctx: ExtensionContext, includeMessages = true): Promise<TeamSnapshot> {
194
201
  if (!child && !opening && !connection) 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 };
195
202
  const status = await handle("agent", { action: "list" }, ctx) as Pick<TeamSnapshot, "members" | "limit" | "depth"> & { lastMessage: number };
196
203
  const tasks: TaskView[] = [];
@@ -200,7 +207,7 @@ export default function teams(pi: ExtensionAPI) {
200
207
  tasks.push(...page.tasks); total = page.total;
201
208
  if (!page.tasks.length) break;
202
209
  } while (tasks.length < total);
203
- const messages = await handle("message", { action: "history", after: Math.max(0, status.lastMessage - 100), limit: 100 }, ctx) as Message[];
210
+ const messages = includeMessages ? await handle("message", { action: "history", after: Math.max(0, status.lastMessage - 100), limit: 100 }, ctx) as Message[] : [];
204
211
  return { members: status.members, limit: status.limit, depth: status.depth, tasks, messages };
205
212
  }
206
213
  function host(ctx: ExtensionContext): PanelHost {
@@ -211,7 +218,7 @@ export default function teams(pi: ExtensionAPI) {
211
218
  return await coordinator!.handle(coordinator!.self, "ui", value) as string;
212
219
  };
213
220
  const api: PanelHost = {
214
- self: memberId(), child, drafts, snapshot: () => snapshot(ctx),
221
+ self: memberId(), child, drafts, snapshot: includeMessages => snapshot(ctx, includeMessages),
215
222
  visit: async id => {
216
223
  if (id === memberId()) return;
217
224
  if (!child) await link(ctx);
@@ -273,13 +280,13 @@ export default function teams(pi: ExtensionAPI) {
273
280
  if (change) report(ctx, change);
274
281
  });
275
282
  function output(data: unknown) { return { content: [{ type: "text" as const, text: JSON.stringify(data) }], details: data }; }
276
- const guidance = "Team launches use real long-lived Pi sessions. Idle members count against the shared ceiling. Use stable member/task IDs; query status and history instead of acknowledging notifications. Limits cover only ac-teams, not other launchers or shell commands.";
283
+ const guidance = "Team launches use real long-lived Pi sessions. Idle members count against the shared ceiling and retain their processes and extensions. Check team_agent list resources before adding concurrency; teammate launch/resume results include pre-launch host headroom. Resource warnings are advisory, not a memory reservation or capacity guarantee. Sequence launches while members are starting or memory is pressured; stop unneeded idle members and resume their saved sessions later. Use teammate names for messaging and stable IDs for member control and tasks; query status and history instead of acknowledging notifications. Limits cover only ac-teams, not other launchers or shell commands.";
277
284
  const taskTrackingGuidance = "Before delegating a deliverable through team_agent, create or reuse a team_task and include its ID in the member's brief. Use one shared task list for your own work and delegated work; do not create one task per teammate by default. Quick lookups and brief second opinions need no separate task unless their results need coordination or follow-up.";
278
285
  pi.registerTool({
279
286
  name: "team_agent", label: "Team agent", description: "Launch/resume Pi members, list/status, stop owned subtrees, change root limits, and explicitly clean stopped worktrees. Create or reuse team_task entries for delegated deliverables; quick lookups need no task unless results need coordination or follow-up.",
280
287
  promptGuidelines: [guidance, taskTrackingGuidance, "Delegation needs the user's authorization. Give each new member a unique descriptive name (one or two lowercase words, hyphenated if two), plus a bounded brief. Names remain stable on resume. Avoid concurrent writers in the same cwd. Worktrees are opt-in."],
281
288
  parameters: AgentSchema,
282
- ...teamToolRenderers("team_agent"),
289
+ ...teamToolRenderers("team_agent", memberLabels),
283
290
  async execute(_id, args, _signal, _update, ctx) { return output(await handle("agent", args, ctx)); },
284
291
  });
285
292
  pi.registerTool({
@@ -294,7 +301,7 @@ export default function teams(pi: ExtensionAPI) {
294
301
  "team_task start refuses another owner's task or unfinished dependencies. Complete requires ownership, in-progress work, and acceptance=true. Release clears ownership, returns in-progress work to todo, and leaves done work done. Reopen is explicit and cannot invalidate active or done dependents.",
295
302
  ],
296
303
  parameters: TaskSchema,
297
- ...teamToolRenderers("team_task"),
304
+ ...teamToolRenderers("team_task", memberLabels),
298
305
  async execute(_id, args, _signal, _update, ctx) {
299
306
  const data = await handle("task", args, ctx) as TaskView | { tasks: TaskView[]; total: number };
300
307
  const compact = (task: TaskView) => ({ id: task.id, number: task.number, label: task.label, title: task.title, status: task.status, owner: task.owner, revision: task.revision, dependencies: task.dependencies, blockedBy: task.blockedBy });
@@ -303,10 +310,10 @@ export default function teams(pi: ExtensionAPI) {
303
310
  },
304
311
  });
305
312
  pi.registerTool({
306
- name: "team_message", label: "Team message", description: "Send a persistent direct/team message or read cursor-paged history. Send returns a receipt; history and search return full messages. New messages queue a compact follow-up notification for each recipient.",
313
+ name: "team_message", label: "Team message", description: "Send a persistent direct/team message to teammate names or IDs, or read cursor-paged history. Send returns a receipt with resolved IDs; history and search return full messages. New messages queue a compact follow-up notification for each recipient.",
307
314
  promptGuidelines: ["Do not poll team_message history while waiting for replies; notifications trigger a follow-up turn. On notification, read team_message history after your last read cursor and advance it through the returned messages. An empty result means no newer messages, not that a teammate has finished."],
308
315
  parameters: MessageSchema,
309
- ...teamToolRenderers("team_message"),
316
+ ...teamToolRenderers("team_message", memberLabels),
310
317
  async execute(_id, args, _signal, _update, ctx) {
311
318
  const result = await handle("message", args, ctx);
312
319
  if (args.action === "send") {
package/src/render.ts CHANGED
@@ -46,26 +46,30 @@ 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);
49
+ type MemberLabels = () => Readonly<Record<string, string>>;
50
+ const defaultLabels: MemberLabels = () => ({ root: "Main" });
51
+ const memberLabel = (id: unknown, labels: Readonly<Record<string, string>>) => inline(typeof id === "string" && Object.hasOwn(labels, id) ? labels[id] : id);
52
+
53
+ function preview(items: unknown[], count: string, theme: Theme, labels: Readonly<Record<string, string>>): string {
54
+ const entries = items.slice(0, 3).map(item => toolSummary(item, theme, labels)).filter(Boolean);
51
55
  if (items.length > 3) entries.push(`+${items.length - 3} more`);
52
56
  return [count, ...entries].join(" · ");
53
57
  }
54
58
 
55
- function toolSummary(data: unknown, theme: Theme): string {
56
- if (Array.isArray(data)) return preview(data, `${data.length} messages`, theme);
59
+ function toolSummary(data: unknown, theme: Theme, labels: Readonly<Record<string, string>>): string {
60
+ if (Array.isArray(data)) return preview(data, `${data.length} messages`, theme, labels);
57
61
  if (data && typeof data === "object") {
58
62
  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.total === undefined ? `${value.tasks.length} tasks` : `${value.tasks.length} of ${value.total} tasks`, theme);
61
- const label = value.title ?? value.name ?? value.brief ?? value.text ?? value.query ?? value.id;
63
+ if (Array.isArray(value.members)) return preview(value.members, `${value.members.length} members`, theme, labels);
64
+ if (Array.isArray(value.tasks)) return preview(value.tasks, value.total === undefined ? `${value.tasks.length} tasks` : `${value.tasks.length} of ${value.total} tasks`, theme, labels);
65
+ const label = value.title ?? value.name ?? value.brief ?? value.text ?? value.query ?? (Array.isArray(value.recipients) ? undefined : value.id === undefined ? undefined : memberLabel(value.id, labels));
62
66
  const status = value.status === undefined ? "" : inline(value.status);
63
67
  const color = status === "done" ? "success"
64
68
  : status === "interrupted" ? "error"
65
69
  : status === "busy" || status === "in_progress" ? "accent"
66
70
  : status === "todo" || status === "starting" || status === "stopping" ? "warning" : "muted";
67
71
  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(", ")}`);
72
+ if (Array.isArray(value.recipients)) parts.push(`${value.from === undefined ? "to" : `${memberLabel(value.from, labels)} →`} ${value.recipients.map(id => memberLabel(id, labels)).join(", ")}`);
69
73
  for (const key of ["seq", "limit", "depth"]) {
70
74
  if (value[key] !== undefined) parts.push(`${key}: ${inline(value[key])}`);
71
75
  }
@@ -74,13 +78,13 @@ function toolSummary(data: unknown, theme: Theme): string {
74
78
  return data === undefined || data === null ? "" : inline(data);
75
79
  }
76
80
 
77
- export function teamToolRenderers(name: string): ToolRenderers {
81
+ export function teamToolRenderers(name: string, getLabels: MemberLabels = defaultLabels): ToolRenderers {
78
82
  return {
79
83
  renderShell: "self",
80
84
  renderCall(args, theme, context) {
81
85
  if (!context.isPartial) return new Container();
82
86
  const { action, ...fields } = (args ?? {}) as Record<string, unknown>;
83
- const request = toolSummary(fields, theme);
87
+ const request = toolSummary(fields, theme, getLabels());
84
88
  const text = `${theme.fg("warning", "●")} ${theme.fg("toolTitle", name)} · ${inline(action ?? "...")} → running${request ? ` · ${request}` : ""}`;
85
89
  return new TruncatedText(text, context.outputPad, 0);
86
90
  },
@@ -92,8 +96,9 @@ export function teamToolRenderers(name: string): ToolRenderers {
92
96
  if (data === undefined) {
93
97
  try { data = JSON.parse(text); } catch { data = text; }
94
98
  }
95
- const outcome = context.isError ? `error · ${inline(text)}` : toolSummary(data, theme) || "done";
96
- const request = toolSummary(fields, theme);
99
+ const labels = getLabels();
100
+ const outcome = context.isError ? `error · ${inline(text)}` : toolSummary(data, theme, labels) || "done";
101
+ const request = toolSummary(fields, theme, labels);
97
102
  const heading = `${theme.fg(context.isError ? "error" : "success", "●")} ${theme.fg("toolTitle", name)} · ${inline(action ?? "...")} → ${outcome}${request && !outcome.includes(request) ? ` · ${request}` : ""}`;
98
103
  const requestJSON = cleanText(JSON.stringify(context.args, null, 2));
99
104
  const responseJSON = cleanText(result.details === undefined ? text : JSON.stringify(result.details, null, 2));
@@ -122,10 +127,15 @@ export function teamToolRenderers(name: string): ToolRenderers {
122
127
  };
123
128
  }
124
129
 
125
- export const renderTeamMessage: MessageRenderer = (message, { expanded }, theme) => {
126
- const details = message.details as { through?: number; messages?: { from: string; text: string; recipients: string[] }[] } | undefined;
127
- const recent = details?.messages?.at(-1);
128
- const name = (id: string) => id === "root" ? "Main" : id;
129
- const preview = recent ? `${name(recent.from)} → ${recent.recipients.map(name).join(", ")} · ${recent.text.split("\n")[0]}` : `Updates through ${details?.through ?? "unknown"}`;
130
- return entry("Team message", details ?? { content: message.content }, expanded, theme, false, preview);
131
- };
130
+ export function teamMessageRenderer(getLabels: MemberLabels = defaultLabels): MessageRenderer {
131
+ return (message, { expanded }, theme) => {
132
+ const details = message.details as { through?: number; messages?: { from: string; text: string; recipients: string[] }[] } | undefined;
133
+ const recent = details?.messages?.at(-1);
134
+ const labels = getLabels();
135
+ const name = (id: string) => memberLabel(id, labels);
136
+ const preview = recent ? `${name(recent.from)} → ${recent.recipients.map(name).join(", ")} · ${recent.text.split("\n")[0]}` : `Updates through ${details?.through ?? "unknown"}`;
137
+ return entry("Team message", details ?? { content: message.content }, expanded, theme, false, preview);
138
+ };
139
+ }
140
+
141
+ export const renderTeamMessage = teamMessageRenderer();
@@ -0,0 +1,44 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ export interface HostResources {
4
+ scope: "host";
5
+ memory: { totalBytes: number; availableBytes: number; swapUsedBytes: number } | null;
6
+ /** Percentage of wall time stalled on memory over the last ten seconds (Linux PSI). */
7
+ memoryPressure: { someAvg10: number; fullAvg10: number } | null;
8
+ warnings: string[];
9
+ }
10
+
11
+ /** On-demand advisory only: no process walks, subprocesses, timers, or per-member budget. */
12
+ export function hostResources(read: (path: string) => string = path => readFileSync(path, "utf8"), platform: string = process.platform): HostResources {
13
+ const result: HostResources = { scope: "host", memory: null, memoryPressure: null, warnings: [] };
14
+ if (platform === "linux") {
15
+ try {
16
+ const text = read("/proc/meminfo");
17
+ const bytes = (key: string) => {
18
+ const match = text.match(new RegExp(`^${key}:\\s+(\\d+) kB$`, "m"));
19
+ return match ? Number(match[1]) * 1024 : NaN;
20
+ };
21
+ const totalBytes = bytes("MemTotal"), availableBytes = bytes("MemAvailable");
22
+ const swapTotal = bytes("SwapTotal"), swapFree = bytes("SwapFree");
23
+ if ([totalBytes, availableBytes, swapTotal, swapFree].every(Number.isSafeInteger) && totalBytes > 0 && availableBytes <= totalBytes && swapFree <= swapTotal) {
24
+ result.memory = { totalBytes, availableBytes, swapUsedBytes: swapTotal - swapFree };
25
+ if (availableBytes < totalBytes * 0.1) result.warnings.push("Host available memory is below 10%. Sequence work; stop unneeded idle members and resume their saved sessions later.");
26
+ }
27
+ } catch { /* Missing or unreadable metrics remain explicitly unavailable. */ }
28
+ try {
29
+ const text = read("/proc/pressure/memory");
30
+ const avg = (kind: string) => {
31
+ const match = text.match(new RegExp(`^${kind} avg10=(\\d+(?:\\.\\d+)?) `, "m"));
32
+ return match ? Number(match[1]) : NaN;
33
+ };
34
+ const someAvg10 = avg("some"), fullAvg10 = avg("full");
35
+ if ([someAvg10, fullAvg10].every(value => Number.isFinite(value) && value >= 0 && value <= 100) && fullAvg10 <= someAvg10) {
36
+ result.memoryPressure = { someAvg10, fullAvg10 };
37
+ if (someAvg10 > 0) result.warnings.push(`Host tasks stalled on memory for ${someAvg10}% of the last ten seconds. Prefer sequential work over more teammates.`);
38
+ }
39
+ } catch { /* PSI is optional on older kernels. */ }
40
+ }
41
+ if (!result.memory) result.warnings.push("Host memory headroom is unavailable; do not assume another teammate fits.");
42
+ if (!result.memoryPressure) result.warnings.push("Host memory pressure is unavailable; recent stalls cannot be assessed.");
43
+ return result;
44
+ }
package/src/schemas.ts CHANGED
@@ -36,7 +36,7 @@ export const TaskSchema = Type.Object({
36
36
  }, { additionalProperties: false });
37
37
  export const MessageSchema = Type.Object({
38
38
  action: Type.Union(["send", "history", "search"].map(v => Type.Literal(v))),
39
- recipients: Type.Optional(Type.Array(id, { minItems: 1, maxItems: 100, uniqueItems: true })),
39
+ recipients: Type.Optional(Type.Array(id, { minItems: 1, maxItems: 100, uniqueItems: true, description: "Exact teammate names or member IDs. Each must resolve to a different live member. Omit to notify all other live members." })),
40
40
  text: Type.Optional(text), query: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
41
41
  after: Type.Optional(Type.Integer({ minimum: 0 })), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
42
42
  }, { additionalProperties: false });
package/src/store.ts CHANGED
@@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
4
4
  import { stateRoot } from "./config.ts";
5
5
  import { validatePolicy } from "./egress.ts";
6
6
  import { stripVTControlCharacters } from "node:util";
7
- import { isDescendant, isLive, isMain, memberNamePattern, memberNameMaxLength, taskLabelMaxLength, taskLabelPattern } from "./types.ts";
7
+ import { isDescendant, isLive, isMain, memberName, memberNamePattern, memberNameMaxLength, taskLabelMaxLength, taskLabelPattern } from "./types.ts";
8
8
  import type { TeamState, TaskArgs, MessageArgs, Task, TaskView, Message, Worktree } from "./types.ts";
9
9
 
10
10
  // UI envelopes add task context after the public 16,000-character input limit.
@@ -183,6 +183,20 @@ export function readState(dir: string): TeamState {
183
183
  } finally { closeSync(fd); }
184
184
  }
185
185
 
186
+ /** Cache only display names, not histories; atomic snapshot replacements invalidate the cache. */
187
+ export function createMemberLabels(): (dir: string) => Record<string, string> {
188
+ let key: string | undefined, labels: Record<string, string>;
189
+ return dir => {
190
+ const file = join(dir, "state.json"), stat = lstatSync(file, { bigint: true });
191
+ const next = `${file}:${stat.dev}:${stat.ino}:${stat.mtimeNs}:${stat.size}:${stat.mode}:${stat.uid}:${stat.nlink}`;
192
+ if (next !== key) {
193
+ labels = Object.fromEntries(Object.values(readState(dir).members).map(member => [member.id, memberName(member)]));
194
+ key = next;
195
+ }
196
+ return labels;
197
+ };
198
+ }
199
+
186
200
  export class Store {
187
201
  readonly dir: string;
188
202
  state: TeamState;
@@ -360,8 +374,11 @@ export class Store {
360
374
  if (args.action === "send") return this.update(state => {
361
375
  const limit = enveloped ? maxMessageText : 16000;
362
376
  requireValue(text(args.text, limit), `Message text must be nonempty and at most ${limit} characters`);
363
- const recipients = args.recipients === undefined ? Object.values(state.members).filter(member => member.id !== actor && isLive(member)).map(member => member.id) : args.recipients;
364
- requireValue(Array.isArray(recipients) && (args.recipients === undefined || recipients.length > 0) && new Set(recipients).size === recipients.length && recipients.every(recipient => typeof recipient === "string" && Object.hasOwn(state.members, recipient) && isLive(state.members[recipient])), "Recipients must be unique live member IDs");
377
+ const members = Object.values(state.members);
378
+ requireValue(args.recipients === undefined || Array.isArray(args.recipients), "Recipients must be unique live member names or IDs");
379
+ const recipients = args.recipients === undefined ? members.filter(member => member.id !== actor && isLive(member)).map(member => member.id)
380
+ : args.recipients.map(recipient => typeof recipient === "string" ? members.find(member => member.id === recipient || member.name === recipient)?.id : undefined);
381
+ requireValue((args.recipients === undefined || recipients.length > 0) && new Set(recipients).size === recipients.length && recipients.every((recipient): recipient is string => recipient !== undefined && isLive(state.members[recipient])), "Recipients must be unique live member names or IDs");
365
382
  const message = { seq: (state.messages.at(-1)?.seq ?? 0) + 1, id: randomUUID(), from: actor, recipients: [...recipients], text: args.text, time: new Date().toISOString() };
366
383
  state.messages.push(message);
367
384
  return structuredClone(message);
@@ -95,7 +95,7 @@ export function installTeamFooter(_pi: ExtensionAPI, ctx: ExtensionContext, host
95
95
  redraw();
96
96
  return;
97
97
  }
98
- const next = await host.snapshot();
98
+ const next = await host.snapshot(false);
99
99
  if (disposed) return;
100
100
  members = next.members.filter(isLive); tasks = next.tasks;
101
101
  const count = ids().length;
package/src/ui-types.ts CHANGED
@@ -12,7 +12,7 @@ export interface PanelHost {
12
12
  self: string;
13
13
  /** True in a member session; false in a main session. */
14
14
  child: boolean;
15
- snapshot(): Promise<TeamSnapshot>;
15
+ snapshot(includeMessages?: boolean): Promise<TeamSnapshot>;
16
16
  visit(id: string): Promise<void>;
17
17
  requestTask(text: string): Promise<string>;
18
18
  feedback(task: TaskView, text: string): Promise<string>;