ac-teams 0.0.12 → 0.0.13

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.
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.13",
4
4
  "description": "Small, persistent teams for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -7,8 +7,8 @@ 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";
@@ -48,7 +48,13 @@ 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
+ function memberLabels(): Record<string, string> {
52
+ 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)]));
55
+ } catch { return { root: "Main" }; } // A transcript can render before its team is available.
56
+ }
57
+ pi.registerMessageRenderer("ac-teams", teamMessageRenderer(memberLabels));
52
58
  const warn = (message: string) => context?.ui.notify(message, "warning");
53
59
  const notify = (through: number, messages: Message[] = []) => {
54
60
  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 });
@@ -273,13 +279,13 @@ export default function teams(pi: ExtensionAPI) {
273
279
  if (change) report(ctx, change);
274
280
  });
275
281
  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.";
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.";
277
283
  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
284
  pi.registerTool({
279
285
  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
286
  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
287
  parameters: AgentSchema,
282
- ...teamToolRenderers("team_agent"),
288
+ ...teamToolRenderers("team_agent", memberLabels),
283
289
  async execute(_id, args, _signal, _update, ctx) { return output(await handle("agent", args, ctx)); },
284
290
  });
285
291
  pi.registerTool({
@@ -294,7 +300,7 @@ export default function teams(pi: ExtensionAPI) {
294
300
  "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
301
  ],
296
302
  parameters: TaskSchema,
297
- ...teamToolRenderers("team_task"),
303
+ ...teamToolRenderers("team_task", memberLabels),
298
304
  async execute(_id, args, _signal, _update, ctx) {
299
305
  const data = await handle("task", args, ctx) as TaskView | { tasks: TaskView[]; total: number };
300
306
  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 +309,10 @@ export default function teams(pi: ExtensionAPI) {
303
309
  },
304
310
  });
305
311
  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.",
312
+ 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
313
  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
314
  parameters: MessageSchema,
309
- ...teamToolRenderers("team_message"),
315
+ ...teamToolRenderers("team_message", memberLabels),
310
316
  async execute(_id, args, _signal, _update, ctx) {
311
317
  const result = await handle("message", args, ctx);
312
318
  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();
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
@@ -360,8 +360,11 @@ export class Store {
360
360
  if (args.action === "send") return this.update(state => {
361
361
  const limit = enveloped ? maxMessageText : 16000;
362
362
  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");
363
+ const members = Object.values(state.members);
364
+ requireValue(args.recipients === undefined || Array.isArray(args.recipients), "Recipients must be unique live member names or IDs");
365
+ const recipients = args.recipients === undefined ? members.filter(member => member.id !== actor && isLive(member)).map(member => member.id)
366
+ : args.recipients.map(recipient => typeof recipient === "string" ? members.find(member => member.id === recipient || member.name === recipient)?.id : undefined);
367
+ 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
368
  const message = { seq: (state.messages.at(-1)?.seq ?? 0) + 1, id: randomUUID(), from: actor, recipients: [...recipients], text: args.text, time: new Date().toISOString() };
366
369
  state.messages.push(message);
367
370
  return structuredClone(message);