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 +1 -1
- package/docs/operations.md +24 -0
- package/docs/usage.md +6 -2
- package/package.json +1 -1
- package/src/coordinator.ts +36 -20
- package/src/index.ts +19 -12
- package/src/render.ts +29 -19
- package/src/resources.ts +44 -0
- package/src/schemas.ts +1 -1
- package/src/store.ts +20 -3
- package/src/terminal-ui.ts +1 -1
- package/src/ui-types.ts +1 -1
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
|
|
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/operations.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
package/src/coordinator.ts
CHANGED
|
@@ -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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
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(
|
|
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
|
|
96
|
-
const
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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/resources.ts
ADDED
|
@@ -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
|
|
364
|
-
requireValue(
|
|
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);
|
package/src/terminal-ui.ts
CHANGED
|
@@ -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>;
|