ac-teams 0.0.13 → 0.0.15

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.
@@ -31,6 +31,36 @@ 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.
39
+
40
+ `someAvg10` and `fullAvg10` cover ten-second trends; `fullAvg60` covers a sixty-second trend. Missing or invalid metrics are `null` with a warning, not zero or an all-clear.
41
+
42
+ The check reads only `/proc/meminfo` and `/proc/pressure/memory` on demand. It adds no polling, process-tree scans, or subprocesses.
43
+
44
+ Available RAM below 10% triggers a low-headroom warning (a heuristic, not a per-member budget). Low headroom alone does not prescribe sequential work.
45
+
46
+ Memory contention warns when `fullAvg60 >= 5` or `fullAvg10 >= 10`. Full pressure measures time when all non-idle work is stalled on memory. Partial pressure (`someAvg10`) remains informational; occupied swap alone does not trigger a pressure warning.
47
+
48
+ These thresholds are advisory defaults, not measured limits for optimal concurrency. Consider reducing concurrency if contention is slowing progress.
49
+
50
+ Starting members reserve count slots synchronously, but may not yet consume their full memory. Concurrent launch results warn about earlier starts.
51
+
52
+ Choose concurrency using resource readings and observed progress. 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.
53
+
54
+ Idle members keep Pi and its extensions alive. Stop unneeded members to release their owned processes, then resume their saved sessions when needed.
55
+
56
+ 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.
57
+
58
+ ### Avoid repeated work without dropping updates
59
+
60
+ 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.
61
+
62
+ 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.
63
+
34
64
  ## Display preferences are global
35
65
 
36
66
  `/teams config` saves task-summary and agent-selector visibility to `~/.pi/ac-teams/display.json`:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ac-teams",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
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 in host headroom.`);
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
@@ -13,7 +13,7 @@ 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,10 +48,11 @@ 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
+ const cachedLabels = createMemberLabels();
51
52
  function memberLabels(): Record<string, string> {
52
53
  try {
53
- const members = coordinator?.store.state.members ?? readState(child ? dirname(environment[0]!) : join(teamsHome(), teamId(context!))).members;
54
- return Object.fromEntries(Object.values(members).map(member => [member.id, memberName(member)]));
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!)));
55
56
  } catch { return { root: "Main" }; } // A transcript can render before its team is available.
56
57
  }
57
58
  pi.registerMessageRenderer("ac-teams", teamMessageRenderer(memberLabels));
@@ -196,7 +197,7 @@ export default function teams(pi: ExtensionAPI) {
196
197
  }
197
198
  return coordinator!.handle(coordinator!.self, operation, args, authorized);
198
199
  }
199
- async function snapshot(ctx: ExtensionContext): Promise<TeamSnapshot> {
200
+ async function snapshot(ctx: ExtensionContext, includeMessages = true): Promise<TeamSnapshot> {
200
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 };
201
202
  const status = await handle("agent", { action: "list" }, ctx) as Pick<TeamSnapshot, "members" | "limit" | "depth"> & { lastMessage: number };
202
203
  const tasks: TaskView[] = [];
@@ -206,7 +207,7 @@ export default function teams(pi: ExtensionAPI) {
206
207
  tasks.push(...page.tasks); total = page.total;
207
208
  if (!page.tasks.length) break;
208
209
  } while (tasks.length < total);
209
- 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[] : [];
210
211
  return { members: status.members, limit: status.limit, depth: status.depth, tasks, messages };
211
212
  }
212
213
  function host(ctx: ExtensionContext): PanelHost {
@@ -217,7 +218,7 @@ export default function teams(pi: ExtensionAPI) {
217
218
  return await coordinator!.handle(coordinator!.self, "ui", value) as string;
218
219
  };
219
220
  const api: PanelHost = {
220
- self: memberId(), child, drafts, snapshot: () => snapshot(ctx),
221
+ self: memberId(), child, drafts, snapshot: includeMessages => snapshot(ctx, includeMessages),
221
222
  visit: async id => {
222
223
  if (id === memberId()) return;
223
224
  if (!child) await link(ctx);
@@ -279,7 +280,7 @@ export default function teams(pi: ExtensionAPI) {
279
280
  if (change) report(ctx, change);
280
281
  });
281
282
  function output(data: unknown) { return { content: [{ type: "text" as const, text: JSON.stringify(data) }], details: data }; }
282
- const guidance = "Team launches use real long-lived Pi sessions. Idle members count against the shared ceiling. 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.";
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. Choose concurrency using resource readings and observed progress, not low headroom alone. If contention slows progress, 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.";
283
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.";
284
285
  pi.registerTool({
285
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.",
@@ -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
+ /** Linux PSI percentages: partial ten-second pressure and full ten-/sixty-second pressure. */
7
+ memoryPressure: { someAvg10: number; fullAvg10: number; fullAvg60: 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%. This is low headroom, not a per-member capacity estimate.");
26
+ }
27
+ } catch { /* Missing or unreadable metrics remain explicitly unavailable. */ }
28
+ try {
29
+ const text = read("/proc/pressure/memory");
30
+ const avg = (kind: string, window: 10 | 60) => {
31
+ const match = text.match(new RegExp(`^${kind} .*?\\bavg${window}=(\\d+(?:\\.\\d+)?) `, "m"));
32
+ return match ? Number(match[1]) : NaN;
33
+ };
34
+ const someAvg10 = avg("some", 10), fullAvg10 = avg("full", 10), fullAvg60 = avg("full", 60);
35
+ if ([someAvg10, fullAvg10, fullAvg60].every(value => Number.isFinite(value) && value >= 0 && value <= 100) && fullAvg10 <= someAvg10) {
36
+ result.memoryPressure = { someAvg10, fullAvg10, fullAvg60 };
37
+ if (fullAvg60 >= 5 || fullAvg10 >= 10) result.warnings.push(`Significant memory contention detected (full avg10: ${fullAvg10}%, full avg60: ${fullAvg60}%). Consider reducing concurrency if progress is slowing.`);
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/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;
@@ -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>;