@tmustier/pi-agent-teams 0.1.2 → 0.3.0
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 +50 -9
- package/docs/claude-parity.md +22 -18
- package/docs/field-notes-teams-setup.md +6 -4
- package/docs/smoke-test-plan.md +139 -0
- package/eslint.config.js +74 -0
- package/extensions/teams/activity-tracker.ts +234 -0
- package/extensions/teams/fs-lock.ts +21 -5
- package/extensions/teams/leader-inbox.ts +175 -0
- package/extensions/teams/leader-info-commands.ts +139 -0
- package/extensions/teams/leader-lifecycle-commands.ts +343 -0
- package/extensions/teams/leader-messaging-commands.ts +148 -0
- package/extensions/teams/leader-plan-commands.ts +96 -0
- package/extensions/teams/leader-spawn-command.ts +57 -0
- package/extensions/teams/leader-task-commands.ts +421 -0
- package/extensions/teams/leader-team-command.ts +312 -0
- package/extensions/teams/leader-teams-tool.ts +227 -0
- package/extensions/teams/leader.ts +260 -1562
- package/extensions/teams/mailbox.ts +54 -29
- package/extensions/teams/names.ts +87 -0
- package/extensions/teams/protocol.ts +241 -0
- package/extensions/teams/spawn-types.ts +21 -0
- package/extensions/teams/task-store.ts +36 -21
- package/extensions/teams/team-config.ts +71 -25
- package/extensions/teams/teammate-rpc.ts +81 -23
- package/extensions/teams/teams-panel.ts +644 -0
- package/extensions/teams/teams-style.ts +62 -0
- package/extensions/teams/teams-ui-shared.ts +89 -0
- package/extensions/teams/teams-widget.ts +182 -0
- package/extensions/teams/worker.ts +100 -138
- package/extensions/teams/worktree.ts +4 -7
- package/package.json +32 -5
- package/scripts/integration-claim-test.mts +157 -0
- package/scripts/integration-todo-test.mts +532 -0
- package/scripts/lib/pi-workers.ts +105 -0
- package/scripts/smoke-test.mts +424 -0
- package/skills/agent-teams/SKILL.md +139 -0
- package/tsconfig.strict.json +22 -0
- package/extensions/teams/tasks.ts +0 -95
- package/scripts/smoke-test.mjs +0 -199
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ThemeColor } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { visibleWidth } from "@mariozechner/pi-tui";
|
|
3
|
+
import type { TeamConfig, TeamMember } from "./team-config.js";
|
|
4
|
+
import type { TeamTask } from "./task-store.js";
|
|
5
|
+
import type { TeammateRpc, TeammateStatus } from "./teammate-rpc.js";
|
|
6
|
+
|
|
7
|
+
// Status icon and color mapping (shared by widget + interactive panel)
|
|
8
|
+
export const STATUS_ICON: Record<TeammateStatus, string> = {
|
|
9
|
+
streaming: "\u25c9",
|
|
10
|
+
idle: "\u25cf",
|
|
11
|
+
starting: "\u25cb",
|
|
12
|
+
stopped: "\u2717",
|
|
13
|
+
error: "\u2717",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const STATUS_COLOR: Record<TeammateStatus, ThemeColor> = {
|
|
17
|
+
streaming: "accent",
|
|
18
|
+
idle: "success",
|
|
19
|
+
starting: "muted",
|
|
20
|
+
stopped: "dim",
|
|
21
|
+
error: "error",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const TOOL_VERB: Record<string, string> = {
|
|
25
|
+
read: "reading\u2026",
|
|
26
|
+
edit: "editing\u2026",
|
|
27
|
+
write: "writing\u2026",
|
|
28
|
+
grep: "searching\u2026",
|
|
29
|
+
glob: "finding files\u2026",
|
|
30
|
+
bash: "running\u2026",
|
|
31
|
+
task: "delegating\u2026",
|
|
32
|
+
webfetch: "fetching\u2026",
|
|
33
|
+
websearch: "searching web\u2026",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function toolVerb(toolName: string): string {
|
|
37
|
+
const key = toolName.toLowerCase();
|
|
38
|
+
return TOOL_VERB[key] ?? `${toolName}\u2026`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function toolActivity(toolName: string | null): string {
|
|
42
|
+
if (!toolName) return "";
|
|
43
|
+
return toolVerb(toolName);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function padRight(str: string, targetWidth: number): string {
|
|
47
|
+
const w = visibleWidth(str);
|
|
48
|
+
return w >= targetWidth ? str : str + " ".repeat(targetWidth - w);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resolveStatus(rpc: TeammateRpc | undefined, cfg: TeamMember | undefined): TeammateStatus {
|
|
52
|
+
if (rpc) return rpc.status;
|
|
53
|
+
return cfg?.status === "online" ? "idle" : "stopped";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function formatTokens(n: number): string {
|
|
57
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
58
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
59
|
+
return String(n);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Compute the set of worker names that should be visible in the UI.
|
|
64
|
+
*
|
|
65
|
+
* Rule: show any worker that is:
|
|
66
|
+
* - currently spawned/known as a teammate RPC
|
|
67
|
+
* - online in team config
|
|
68
|
+
* - owning an in-progress task (even if RPC is disconnected)
|
|
69
|
+
*/
|
|
70
|
+
export function getVisibleWorkerNames(opts: {
|
|
71
|
+
teammates: ReadonlyMap<string, TeammateRpc>;
|
|
72
|
+
teamConfig: TeamConfig | null;
|
|
73
|
+
tasks: readonly TeamTask[];
|
|
74
|
+
}): string[] {
|
|
75
|
+
const { teammates, teamConfig, tasks } = opts;
|
|
76
|
+
const leadName = teamConfig?.leadName;
|
|
77
|
+
const cfgMembers = teamConfig?.members ?? [];
|
|
78
|
+
|
|
79
|
+
const names = new Set<string>();
|
|
80
|
+
for (const name of teammates.keys()) names.add(name);
|
|
81
|
+
for (const m of cfgMembers) {
|
|
82
|
+
if (m.role === "worker" && m.status === "online") names.add(m.name);
|
|
83
|
+
}
|
|
84
|
+
for (const t of tasks) {
|
|
85
|
+
if (t.owner && t.owner !== leadName && t.status === "in_progress") names.add(t.owner);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return Array.from(names).sort();
|
|
89
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
2
|
+
import type { Component, TUI } from "@mariozechner/pi-tui";
|
|
3
|
+
import type { Theme, ThemeColor } from "@mariozechner/pi-coding-agent";
|
|
4
|
+
import type { TeammateRpc, TeammateStatus } from "./teammate-rpc.js";
|
|
5
|
+
import type { ActivityTracker } from "./activity-tracker.js";
|
|
6
|
+
import type { TeamTask } from "./task-store.js";
|
|
7
|
+
import type { TeamConfig, TeamMember } from "./team-config.js";
|
|
8
|
+
import type { TeamsStyle } from "./teams-style.js";
|
|
9
|
+
import { formatMemberDisplayName, getTeamsStrings } from "./teams-style.js";
|
|
10
|
+
import {
|
|
11
|
+
STATUS_COLOR,
|
|
12
|
+
STATUS_ICON,
|
|
13
|
+
formatTokens,
|
|
14
|
+
getVisibleWorkerNames,
|
|
15
|
+
padRight,
|
|
16
|
+
resolveStatus,
|
|
17
|
+
toolActivity,
|
|
18
|
+
} from "./teams-ui-shared.js";
|
|
19
|
+
|
|
20
|
+
export interface WidgetDeps {
|
|
21
|
+
getTeammates(): Map<string, TeammateRpc>;
|
|
22
|
+
getTracker(): ActivityTracker;
|
|
23
|
+
getTasks(): TeamTask[];
|
|
24
|
+
getTeamConfig(): TeamConfig | null;
|
|
25
|
+
getStyle(): TeamsStyle;
|
|
26
|
+
isDelegateMode(): boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type WidgetFactory = (tui: TUI, theme: Theme) => Component;
|
|
30
|
+
|
|
31
|
+
interface WidgetRow {
|
|
32
|
+
icon: string; // raw char (before styling)
|
|
33
|
+
iconColor: ThemeColor;
|
|
34
|
+
displayName: string;
|
|
35
|
+
statusKey: TeammateStatus;
|
|
36
|
+
pending: number;
|
|
37
|
+
completed: number;
|
|
38
|
+
tokensStr: string; // "—" for chairman
|
|
39
|
+
activityText: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createTeamsWidget(deps: WidgetDeps): WidgetFactory {
|
|
43
|
+
return (_tui: TUI, theme: Theme): Component => {
|
|
44
|
+
return {
|
|
45
|
+
render(width: number): string[] {
|
|
46
|
+
const teammates = deps.getTeammates();
|
|
47
|
+
const tracker = deps.getTracker();
|
|
48
|
+
const tasks = deps.getTasks();
|
|
49
|
+
const teamConfig = deps.getTeamConfig();
|
|
50
|
+
const style = deps.getStyle();
|
|
51
|
+
const strings = getTeamsStrings(style);
|
|
52
|
+
const delegateMode = deps.isDelegateMode();
|
|
53
|
+
|
|
54
|
+
// Hide when no active team state
|
|
55
|
+
const hasOnlineMembers = (teamConfig?.members ?? []).some(
|
|
56
|
+
(m) => m.role === "worker" && m.status === "online",
|
|
57
|
+
);
|
|
58
|
+
if (teammates.size === 0 && tasks.length === 0 && !hasOnlineMembers) {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const lines: string[] = [];
|
|
63
|
+
|
|
64
|
+
// ── Header line ──
|
|
65
|
+
let header = " " + theme.bold(theme.fg("accent", "Teams"));
|
|
66
|
+
if (delegateMode) header += " " + theme.fg("warning", "[delegate]");
|
|
67
|
+
lines.push(truncateToWidth(header, width));
|
|
68
|
+
|
|
69
|
+
// ── Build row data ──
|
|
70
|
+
const cfgMembers = teamConfig?.members ?? [];
|
|
71
|
+
const cfgByName = new Map<string, TeamMember>();
|
|
72
|
+
for (const m of cfgMembers) cfgByName.set(m.name, m);
|
|
73
|
+
|
|
74
|
+
const rows: WidgetRow[] = [];
|
|
75
|
+
|
|
76
|
+
// Chairman row (always first when team is active)
|
|
77
|
+
const leadName = teamConfig?.leadName;
|
|
78
|
+
if (leadName) {
|
|
79
|
+
const leadTasks = tasks.filter((t) => t.owner === leadName);
|
|
80
|
+
rows.push({
|
|
81
|
+
icon: "\u25c6",
|
|
82
|
+
iconColor: "accent",
|
|
83
|
+
displayName: strings.leaderTitle,
|
|
84
|
+
statusKey: "idle",
|
|
85
|
+
pending: leadTasks.filter((t) => t.status === "pending").length,
|
|
86
|
+
completed: leadTasks.filter((t) => t.status === "completed").length,
|
|
87
|
+
tokensStr: "\u2014",
|
|
88
|
+
activityText: "",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const workerNames = getVisibleWorkerNames({ teammates, teamConfig, tasks });
|
|
93
|
+
if (workerNames.length === 0 && rows.length === 0) {
|
|
94
|
+
lines.push(
|
|
95
|
+
truncateToWidth(
|
|
96
|
+
" " + theme.fg("dim", `(no ${strings.memberTitle.toLowerCase()}s) /team spawn <name>`),
|
|
97
|
+
width,
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
} else {
|
|
101
|
+
for (const name of workerNames) {
|
|
102
|
+
const rpc = teammates.get(name);
|
|
103
|
+
const cfg = cfgByName.get(name);
|
|
104
|
+
const statusKey = resolveStatus(rpc, cfg);
|
|
105
|
+
const activity = tracker.get(name);
|
|
106
|
+
const owned = tasks.filter((t) => t.owner === name);
|
|
107
|
+
|
|
108
|
+
rows.push({
|
|
109
|
+
icon: STATUS_ICON[statusKey],
|
|
110
|
+
iconColor: STATUS_COLOR[statusKey],
|
|
111
|
+
displayName: formatMemberDisplayName(style, name),
|
|
112
|
+
statusKey,
|
|
113
|
+
pending: owned.filter((t) => t.status === "pending").length,
|
|
114
|
+
completed: owned.filter((t) => t.status === "completed").length,
|
|
115
|
+
tokensStr: formatTokens(activity.totalTokens),
|
|
116
|
+
activityText: toolActivity(activity.currentToolName),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Compute column widths ──
|
|
121
|
+
const totalPending = tasks.filter((t) => t.status === "pending").length;
|
|
122
|
+
const totalCompleted = tasks.filter((t) => t.status === "completed").length;
|
|
123
|
+
let totalTokensRaw = 0;
|
|
124
|
+
for (const name of workerNames) totalTokensRaw += tracker.get(name).totalTokens;
|
|
125
|
+
const totalTokensStr = formatTokens(totalTokensRaw);
|
|
126
|
+
|
|
127
|
+
const nameColWidth = Math.max(...rows.map((r) => visibleWidth(r.displayName)));
|
|
128
|
+
const pW = Math.max(...rows.map((r) => String(r.pending).length), String(totalPending).length);
|
|
129
|
+
const cW = Math.max(...rows.map((r) => String(r.completed).length), String(totalCompleted).length);
|
|
130
|
+
const tokW = Math.max(...rows.map((r) => r.tokensStr.length), totalTokensStr.length);
|
|
131
|
+
|
|
132
|
+
// ── Render rows ──
|
|
133
|
+
for (const r of rows) {
|
|
134
|
+
const icon = theme.fg(r.iconColor, r.icon);
|
|
135
|
+
const styledName = theme.bold(r.displayName);
|
|
136
|
+
const statusLabel = theme.fg(STATUS_COLOR[r.statusKey], padRight(r.statusKey, 9));
|
|
137
|
+
const pNum = String(r.pending).padStart(pW);
|
|
138
|
+
const cNum = String(r.completed).padStart(cW);
|
|
139
|
+
const tokStr = r.tokensStr.padStart(tokW);
|
|
140
|
+
const cols = theme.fg(
|
|
141
|
+
"dim",
|
|
142
|
+
` \u00b7 ${pNum} pending \u00b7 ${cNum} complete \u00b7 ${tokStr} tokens`,
|
|
143
|
+
);
|
|
144
|
+
const actLabel = r.activityText ? " " + theme.fg("warning", r.activityText) : "";
|
|
145
|
+
|
|
146
|
+
const row = ` ${icon} ${padRight(styledName, nameColWidth)} ${statusLabel}${cols}${actLabel}`;
|
|
147
|
+
lines.push(truncateToWidth(row, width));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Total row ──
|
|
151
|
+
const sepLine = " " + theme.fg("dim", "\u2500".repeat(Math.max(0, width - 2)));
|
|
152
|
+
lines.push(truncateToWidth(sepLine, width));
|
|
153
|
+
|
|
154
|
+
const totalLabel = theme.bold("Total");
|
|
155
|
+
const totalTaskCount = totalPending + totalCompleted;
|
|
156
|
+
const pct = totalTaskCount > 0 ? Math.round((totalCompleted / totalTaskCount) * 100) : 0;
|
|
157
|
+
const pctLabel = theme.fg("success", padRight(`${pct}%`, 9));
|
|
158
|
+
const tpNum = String(totalPending).padStart(pW);
|
|
159
|
+
const tcNum = String(totalCompleted).padStart(cW);
|
|
160
|
+
const ttokStr = totalTokensStr.padStart(tokW);
|
|
161
|
+
const totalSuffix = theme.fg(
|
|
162
|
+
"muted",
|
|
163
|
+
` \u00b7 ${tpNum} pending \u00b7 ${tcNum} complete \u00b7 ${ttokStr} tokens`,
|
|
164
|
+
);
|
|
165
|
+
// nameColWidth + 4 = " ◆ " + name; then " " + pctLabel fills the status column
|
|
166
|
+
const totalRow = ` ${padRight(totalLabel, nameColWidth + 3)} ${pctLabel}${totalSuffix}`;
|
|
167
|
+
lines.push(truncateToWidth(totalRow, width));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Hints line ──
|
|
171
|
+
const hints = theme.fg(
|
|
172
|
+
"dim",
|
|
173
|
+
" /team widget \u00b7 /team dm <name> <msg> \u00b7 /team task list",
|
|
174
|
+
);
|
|
175
|
+
lines.push(truncateToWidth(hints, width));
|
|
176
|
+
|
|
177
|
+
return lines;
|
|
178
|
+
},
|
|
179
|
+
invalidate() {},
|
|
180
|
+
};
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|
1
|
+
import type { AgentMessage, AgentToolResult } from "@mariozechner/pi-agent-core";
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
3
|
-
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import { Type, type Static } from "@sinclair/typebox";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { popUnreadMessages, writeToMailbox } from "./mailbox.js";
|
|
6
|
+
import { sanitizeName } from "./names.js";
|
|
7
|
+
import { getTeamsStyleFromEnv, type TeamsStyle, getTeamsStrings } from "./teams-style.js";
|
|
8
|
+
import {
|
|
9
|
+
TEAM_MAILBOX_NS,
|
|
10
|
+
isAbortRequestMessage,
|
|
11
|
+
isPlanApprovedMessage,
|
|
12
|
+
isPlanRejectedMessage,
|
|
13
|
+
isSetSessionNameMessage,
|
|
14
|
+
isShutdownRequestMessage,
|
|
15
|
+
isTaskAssignmentMessage,
|
|
16
|
+
} from "./protocol.js";
|
|
6
17
|
import { getTeamDir } from "./paths.js";
|
|
7
18
|
import { ensureTeamConfig, setMemberStatus, upsertMember } from "./team-config.js";
|
|
8
19
|
import {
|
|
@@ -16,31 +27,27 @@ import {
|
|
|
16
27
|
type TeamTask,
|
|
17
28
|
} from "./task-store.js";
|
|
18
29
|
|
|
19
|
-
const TEAM_MAILBOX_NS = "team";
|
|
20
|
-
|
|
21
30
|
function sleep(ms: number): Promise<void> {
|
|
22
31
|
return new Promise((r) => setTimeout(r, ms));
|
|
23
32
|
}
|
|
24
33
|
|
|
25
|
-
function sanitize(name: string): string {
|
|
26
|
-
return name.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
27
|
-
}
|
|
28
|
-
|
|
29
34
|
function teamDirFromEnv(): {
|
|
30
35
|
teamId: string;
|
|
31
36
|
teamDir: string;
|
|
32
37
|
taskListId: string;
|
|
33
38
|
agentName: string;
|
|
34
39
|
leadName: string;
|
|
40
|
+
style: TeamsStyle;
|
|
35
41
|
autoClaim: boolean;
|
|
36
42
|
} | null {
|
|
37
43
|
const teamId = process.env.PI_TEAMS_TEAM_ID;
|
|
38
44
|
const agentNameRaw = process.env.PI_TEAMS_AGENT_NAME;
|
|
39
45
|
if (!teamId || !agentNameRaw) return null;
|
|
40
46
|
|
|
41
|
-
const agentName =
|
|
47
|
+
const agentName = sanitizeName(agentNameRaw);
|
|
42
48
|
const taskListId = process.env.PI_TEAMS_TASK_LIST_ID ?? teamId;
|
|
43
|
-
const
|
|
49
|
+
const style = getTeamsStyleFromEnv(process.env);
|
|
50
|
+
const leadName = sanitizeName(process.env.PI_TEAMS_LEAD_NAME ?? "team-lead");
|
|
44
51
|
const autoClaim = (process.env.PI_TEAMS_AUTO_CLAIM ?? "1") === "1";
|
|
45
52
|
|
|
46
53
|
return {
|
|
@@ -49,32 +56,57 @@ function teamDirFromEnv(): {
|
|
|
49
56
|
taskListId,
|
|
50
57
|
agentName,
|
|
51
58
|
leadName,
|
|
59
|
+
style,
|
|
52
60
|
autoClaim,
|
|
53
61
|
};
|
|
54
62
|
}
|
|
55
63
|
|
|
64
|
+
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|
65
|
+
return typeof value === "object" && value !== null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function hasProperty<K extends string>(value: unknown, key: K): value is Record<K, unknown> & Record<string, unknown> {
|
|
69
|
+
return isObjectRecord(value) && key in value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function hasStringProperty<K extends string>(value: unknown, key: K): value is Record<K, string> & Record<string, unknown> {
|
|
73
|
+
return isObjectRecord(value) && typeof value[key] === "string";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type AssistantMessageWithContent = Record<"role", "assistant"> & Record<"content", unknown> & Record<string, unknown>;
|
|
77
|
+
|
|
78
|
+
function isAssistantMessageWithContent(message: unknown): message is AssistantMessageWithContent {
|
|
79
|
+
return hasStringProperty(message, "role") && message.role === "assistant" && hasProperty(message, "content");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type TextBlock = { type: "text"; text: string };
|
|
83
|
+
|
|
84
|
+
function isTextBlock(block: unknown): block is TextBlock {
|
|
85
|
+
return hasStringProperty(block, "type") && block.type === "text" && hasStringProperty(block, "text");
|
|
86
|
+
}
|
|
87
|
+
|
|
56
88
|
function extractLastAssistantText(messages: AgentMessage[]): string {
|
|
57
|
-
const assistant = messages.filter((m
|
|
58
|
-
const last
|
|
89
|
+
const assistant = messages.filter((m) => isAssistantMessageWithContent(m));
|
|
90
|
+
const last = assistant.at(-1);
|
|
59
91
|
if (!last) return "";
|
|
60
92
|
|
|
61
93
|
const content = last.content;
|
|
62
94
|
if (typeof content === "string") return content;
|
|
63
95
|
if (Array.isArray(content)) {
|
|
64
|
-
return content
|
|
65
|
-
.filter((c) => c && typeof c === "object" && c.type === "text" && typeof (c as any).text === "string")
|
|
66
|
-
.map((c: any) => c.text)
|
|
67
|
-
.join("");
|
|
96
|
+
return content.filter((c) => isTextBlock(c)).map((c) => c.text).join("");
|
|
68
97
|
}
|
|
69
98
|
return "";
|
|
70
99
|
}
|
|
71
100
|
|
|
72
|
-
function buildTaskPrompt(agentName: string, task: TeamTask, planOnly = false): string {
|
|
101
|
+
function buildTaskPrompt(style: TeamsStyle, agentName: string, task: TeamTask, planOnly = false): string {
|
|
102
|
+
const strings = getTeamsStrings(style);
|
|
73
103
|
const footer = planOnly
|
|
74
104
|
? "Produce a detailed implementation plan only. Do NOT make any changes or implement anything yet. Your plan will be reviewed before you can proceed."
|
|
75
105
|
: "Do the work now. When finished, reply with a concise summary and any key outputs.";
|
|
106
|
+
|
|
107
|
+
const actor = style === "soviet" ? strings.memberTitle.toLowerCase() : "teammate";
|
|
76
108
|
return [
|
|
77
|
-
`You are
|
|
109
|
+
`You are ${actor} '${agentName}'.`,
|
|
78
110
|
`You have been assigned task #${task.id}.`,
|
|
79
111
|
`Subject: ${task.subject}`,
|
|
80
112
|
"",
|
|
@@ -84,124 +116,36 @@ function buildTaskPrompt(agentName: string, task: TeamTask, planOnly = false): s
|
|
|
84
116
|
].join("\n");
|
|
85
117
|
}
|
|
86
118
|
|
|
87
|
-
|
|
88
|
-
try {
|
|
89
|
-
const obj = JSON.parse(text);
|
|
90
|
-
if (!obj || typeof obj !== "object") return null;
|
|
91
|
-
if (obj.type !== "task_assignment") return null;
|
|
92
|
-
if (typeof obj.taskId !== "string") return null;
|
|
93
|
-
return {
|
|
94
|
-
taskId: obj.taskId,
|
|
95
|
-
subject: typeof obj.subject === "string" ? obj.subject : undefined,
|
|
96
|
-
description: typeof obj.description === "string" ? obj.description : undefined,
|
|
97
|
-
assignedBy: typeof obj.assignedBy === "string" ? obj.assignedBy : undefined,
|
|
98
|
-
};
|
|
99
|
-
} catch {
|
|
100
|
-
return null;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function isShutdownRequestMessage(text: string): { requestId: string; from?: string; reason?: string; timestamp?: string } | null {
|
|
105
|
-
try {
|
|
106
|
-
const obj = JSON.parse(text);
|
|
107
|
-
if (!obj || typeof obj !== "object") return null;
|
|
108
|
-
if (obj.type !== "shutdown_request") return null;
|
|
109
|
-
if (typeof obj.requestId !== "string") return null;
|
|
110
|
-
return {
|
|
111
|
-
requestId: obj.requestId,
|
|
112
|
-
from: typeof obj.from === "string" ? obj.from : undefined,
|
|
113
|
-
reason: typeof obj.reason === "string" ? obj.reason : undefined,
|
|
114
|
-
timestamp: typeof obj.timestamp === "string" ? obj.timestamp : undefined,
|
|
115
|
-
};
|
|
116
|
-
} catch {
|
|
117
|
-
return null;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function isSetSessionNameMessage(text: string): { name: string } | null {
|
|
122
|
-
try {
|
|
123
|
-
const obj = JSON.parse(text);
|
|
124
|
-
if (!obj || typeof obj !== "object") return null;
|
|
125
|
-
if (obj.type !== "set_session_name") return null;
|
|
126
|
-
if (typeof obj.name !== "string") return null;
|
|
127
|
-
return { name: obj.name };
|
|
128
|
-
} catch {
|
|
129
|
-
return null;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function isAbortRequestMessage(
|
|
134
|
-
text: string,
|
|
135
|
-
): { requestId: string; from?: string; taskId?: string; reason?: string; timestamp?: string } | null {
|
|
136
|
-
try {
|
|
137
|
-
const obj = JSON.parse(text);
|
|
138
|
-
if (!obj || typeof obj !== "object") return null;
|
|
139
|
-
if (obj.type !== "abort_request") return null;
|
|
140
|
-
if (typeof obj.requestId !== "string") return null;
|
|
141
|
-
return {
|
|
142
|
-
requestId: obj.requestId,
|
|
143
|
-
from: typeof obj.from === "string" ? obj.from : undefined,
|
|
144
|
-
taskId: typeof obj.taskId === "string" ? obj.taskId : undefined,
|
|
145
|
-
reason: typeof obj.reason === "string" ? obj.reason : undefined,
|
|
146
|
-
timestamp: typeof obj.timestamp === "string" ? obj.timestamp : undefined,
|
|
147
|
-
};
|
|
148
|
-
} catch {
|
|
149
|
-
return null;
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function isPlanApprovedMessage(text: string): { requestId: string; from: string; timestamp: string } | null {
|
|
154
|
-
try {
|
|
155
|
-
const obj = JSON.parse(text);
|
|
156
|
-
if (!obj || typeof obj !== "object") return null;
|
|
157
|
-
if (obj.type !== "plan_approved") return null;
|
|
158
|
-
if (typeof obj.requestId !== "string" || typeof obj.from !== "string") return null;
|
|
159
|
-
return {
|
|
160
|
-
requestId: obj.requestId,
|
|
161
|
-
from: obj.from,
|
|
162
|
-
timestamp: typeof obj.timestamp === "string" ? obj.timestamp : "",
|
|
163
|
-
};
|
|
164
|
-
} catch {
|
|
165
|
-
return null;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function isPlanRejectedMessage(
|
|
170
|
-
text: string,
|
|
171
|
-
): { requestId: string; from: string; feedback: string; timestamp: string } | null {
|
|
172
|
-
try {
|
|
173
|
-
const obj = JSON.parse(text);
|
|
174
|
-
if (!obj || typeof obj !== "object") return null;
|
|
175
|
-
if (obj.type !== "plan_rejected") return null;
|
|
176
|
-
if (typeof obj.requestId !== "string" || typeof obj.from !== "string") return null;
|
|
177
|
-
return {
|
|
178
|
-
requestId: obj.requestId,
|
|
179
|
-
from: obj.from,
|
|
180
|
-
feedback: typeof obj.feedback === "string" ? obj.feedback : "",
|
|
181
|
-
timestamp: typeof obj.timestamp === "string" ? obj.timestamp : "",
|
|
182
|
-
};
|
|
183
|
-
} catch {
|
|
184
|
-
return null;
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
119
|
+
// Message parsers are shared with the leader implementation.
|
|
188
120
|
export function runWorker(pi: ExtensionAPI): void {
|
|
189
121
|
const env = teamDirFromEnv();
|
|
190
122
|
if (!env) return;
|
|
191
123
|
|
|
192
|
-
const { teamId, teamDir, taskListId, agentName, leadName, autoClaim } = env;
|
|
124
|
+
const { teamId, teamDir, taskListId, agentName, leadName, style, autoClaim } = env;
|
|
125
|
+
|
|
126
|
+
const TeamMessageToolParamsSchema = Type.Object({
|
|
127
|
+
recipient: Type.String({ description: "Name of the comrade to message" }),
|
|
128
|
+
message: Type.String({ description: "The message to send" }),
|
|
129
|
+
});
|
|
130
|
+
// Match the schema at compile-time.
|
|
131
|
+
type TeamMessageToolParams = Static<typeof TeamMessageToolParamsSchema>;
|
|
132
|
+
// Tool result details to match AgentToolResult<TDetails> contract.
|
|
133
|
+
type TeamMessageToolDetails = { recipient: string; timestamp: string };
|
|
193
134
|
|
|
194
135
|
pi.registerTool({
|
|
195
136
|
name: "team_message",
|
|
196
137
|
label: "Team Message",
|
|
197
|
-
description: "Send a message to a
|
|
198
|
-
parameters:
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
138
|
+
description: "Send a message to a comrade. Use this to coordinate with peers on related tasks.",
|
|
139
|
+
parameters: TeamMessageToolParamsSchema,
|
|
140
|
+
async execute(
|
|
141
|
+
_toolCallId,
|
|
142
|
+
params: TeamMessageToolParams,
|
|
143
|
+
_signal,
|
|
144
|
+
_onUpdate,
|
|
145
|
+
_ctx,
|
|
146
|
+
): Promise<AgentToolResult<TeamMessageToolDetails>> {
|
|
147
|
+
const recipient = sanitizeName(params.recipient);
|
|
148
|
+
const message = params.message;
|
|
205
149
|
const ts = new Date().toISOString();
|
|
206
150
|
// Write to recipient's mailbox in team namespace
|
|
207
151
|
await writeToMailbox(teamDir, TEAM_MAILBOX_NS, recipient, {
|
|
@@ -221,7 +165,10 @@ export function runWorker(pi: ExtensionAPI): void {
|
|
|
221
165
|
}),
|
|
222
166
|
timestamp: ts,
|
|
223
167
|
});
|
|
224
|
-
return {
|
|
168
|
+
return {
|
|
169
|
+
content: [{ type: "text", text: `Message sent to ${recipient}` }],
|
|
170
|
+
details: { recipient, timestamp: ts },
|
|
171
|
+
};
|
|
225
172
|
},
|
|
226
173
|
});
|
|
227
174
|
|
|
@@ -388,7 +335,8 @@ export function runWorker(pi: ExtensionAPI): void {
|
|
|
388
335
|
// ignore polling errors
|
|
389
336
|
}
|
|
390
337
|
|
|
391
|
-
|
|
338
|
+
// Add a little jitter to avoid all workers polling/claiming in lock-step.
|
|
339
|
+
await sleep(350 + Math.floor(Math.random() * 200));
|
|
392
340
|
}
|
|
393
341
|
};
|
|
394
342
|
|
|
@@ -404,7 +352,8 @@ export function runWorker(pi: ExtensionAPI): void {
|
|
|
404
352
|
// 1) Assigned tasks
|
|
405
353
|
const requeue: string[] = [];
|
|
406
354
|
while (pendingTaskAssignments.length) {
|
|
407
|
-
const taskId = pendingTaskAssignments.shift()
|
|
355
|
+
const taskId = pendingTaskAssignments.shift();
|
|
356
|
+
if (!taskId) break;
|
|
408
357
|
const task = await getTask(teamDir, taskListId, taskId);
|
|
409
358
|
if (!task) continue;
|
|
410
359
|
if (task.owner !== agentName) continue;
|
|
@@ -421,7 +370,7 @@ export function runWorker(pi: ExtensionAPI): void {
|
|
|
421
370
|
|
|
422
371
|
currentTaskId = taskId;
|
|
423
372
|
isStreaming = true; // optimistic; agent_start will follow
|
|
424
|
-
pi.sendUserMessage(buildTaskPrompt(agentName, task, planMode && !planApproved));
|
|
373
|
+
pi.sendUserMessage(buildTaskPrompt(style, agentName, task, planMode && !planApproved));
|
|
425
374
|
pendingTaskAssignments = [...requeue, ...pendingTaskAssignments];
|
|
426
375
|
return;
|
|
427
376
|
}
|
|
@@ -433,7 +382,7 @@ export function runWorker(pi: ExtensionAPI): void {
|
|
|
433
382
|
pendingDmTexts = [];
|
|
434
383
|
isStreaming = true;
|
|
435
384
|
pi.sendUserMessage([
|
|
436
|
-
{ type: "text", text: "You have received
|
|
385
|
+
{ type: "text", text: "You have received comrade message(s):" },
|
|
437
386
|
{ type: "text", text },
|
|
438
387
|
]);
|
|
439
388
|
return;
|
|
@@ -441,11 +390,15 @@ export function runWorker(pi: ExtensionAPI): void {
|
|
|
441
390
|
|
|
442
391
|
// 3) Auto-claim
|
|
443
392
|
if (autoClaim) {
|
|
393
|
+
// Small randomized delay improves fairness (reduces one fast worker hogging tasks)
|
|
394
|
+
// and reduces lock contention when many workers become idle simultaneously.
|
|
395
|
+
await sleep(Math.floor(Math.random() * 250));
|
|
396
|
+
|
|
444
397
|
const claimed = await claimNextAvailableTask(teamDir, taskListId, agentName, { checkAgentBusy: true });
|
|
445
398
|
if (claimed) {
|
|
446
399
|
currentTaskId = claimed.id;
|
|
447
400
|
isStreaming = true;
|
|
448
|
-
pi.sendUserMessage(buildTaskPrompt(agentName, claimed, planMode && !planApproved));
|
|
401
|
+
pi.sendUserMessage(buildTaskPrompt(style, agentName, claimed, planMode && !planApproved));
|
|
449
402
|
return;
|
|
450
403
|
}
|
|
451
404
|
}
|
|
@@ -459,7 +412,16 @@ export function runWorker(pi: ExtensionAPI): void {
|
|
|
459
412
|
completedStatus?: "completed" | "failed",
|
|
460
413
|
failureReason?: string,
|
|
461
414
|
) => {
|
|
462
|
-
|
|
415
|
+
type IdleNotificationPayload = {
|
|
416
|
+
type: "idle_notification";
|
|
417
|
+
from: string;
|
|
418
|
+
timestamp: string;
|
|
419
|
+
completedTaskId?: string;
|
|
420
|
+
completedStatus?: "completed" | "failed";
|
|
421
|
+
failureReason?: string;
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const payload: IdleNotificationPayload = {
|
|
463
425
|
type: "idle_notification",
|
|
464
426
|
from: agentName,
|
|
465
427
|
timestamp: new Date().toISOString(),
|
|
@@ -494,7 +456,7 @@ export function runWorker(pi: ExtensionAPI): void {
|
|
|
494
456
|
|
|
495
457
|
// Register ourselves in the shared team config so manual tmux workers are discoverable.
|
|
496
458
|
try {
|
|
497
|
-
const cfg = await ensureTeamConfig(teamDir, { teamId, taskListId, leadName });
|
|
459
|
+
const cfg = await ensureTeamConfig(teamDir, { teamId, taskListId, leadName, style });
|
|
498
460
|
const now = new Date().toISOString();
|
|
499
461
|
if (!cfg.members.some((m) => m.name === agentName)) {
|
|
500
462
|
await upsertMember(teamDir, {
|
|
@@ -541,7 +503,7 @@ export function runWorker(pi: ExtensionAPI): void {
|
|
|
541
503
|
// Plan submission: if in plan mode and not yet approved, send plan to leader for review
|
|
542
504
|
// Only do this when we're working on a task and haven't already requested approval.
|
|
543
505
|
if (planMode && !planApproved && currentTaskId && !planRequestId) {
|
|
544
|
-
const lastAssistantText = extractLastAssistantText(event.messages
|
|
506
|
+
const lastAssistantText = extractLastAssistantText(event.messages);
|
|
545
507
|
const reqId = randomUUID();
|
|
546
508
|
planRequestId = reqId;
|
|
547
509
|
const timestamp = new Date().toISOString();
|
|
@@ -570,7 +532,7 @@ export function runWorker(pi: ExtensionAPI): void {
|
|
|
570
532
|
|
|
571
533
|
try {
|
|
572
534
|
if (taskId) {
|
|
573
|
-
const rawResult = extractLastAssistantText(event.messages
|
|
535
|
+
const rawResult = extractLastAssistantText(event.messages);
|
|
574
536
|
const trimmed = rawResult.trim();
|
|
575
537
|
const abortedByRequest = abortTaskId === taskId;
|
|
576
538
|
const aborted = abortedByRequest || trimmed.length === 0;
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
|
-
|
|
5
|
-
function sanitize(name: string): string {
|
|
6
|
-
return name.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
7
|
-
}
|
|
4
|
+
import { sanitizeName } from "./names.js";
|
|
8
5
|
|
|
9
6
|
async function execGit(args: string[], opts: { cwd: string; timeoutMs?: number } ): Promise<{ stdout: string; stderr: string }> {
|
|
10
7
|
return await new Promise((resolve, reject) => {
|
|
@@ -71,8 +68,8 @@ export async function ensureWorktreeCwd(opts: {
|
|
|
71
68
|
// ignore status errors
|
|
72
69
|
}
|
|
73
70
|
|
|
74
|
-
const safeAgent =
|
|
75
|
-
const shortTeam =
|
|
71
|
+
const safeAgent = sanitizeName(opts.agentName);
|
|
72
|
+
const shortTeam = sanitizeName(opts.teamId).slice(0, 12) || "team";
|
|
76
73
|
const branch = `pi-teams/${shortTeam}/${safeAgent}`;
|
|
77
74
|
|
|
78
75
|
const worktreesDir = path.join(opts.teamDir, "worktrees");
|
|
@@ -88,7 +85,7 @@ export async function ensureWorktreeCwd(opts: {
|
|
|
88
85
|
// Create worktree + new branch from HEAD
|
|
89
86
|
await execGit(["worktree", "add", "-b", branch, worktreePath, "HEAD"], { cwd: repoRoot, timeoutMs: 120_000 });
|
|
90
87
|
return { cwd: worktreePath, warnings, mode: "worktree" };
|
|
91
|
-
} catch (err:
|
|
88
|
+
} catch (err: unknown) {
|
|
92
89
|
const msg = err instanceof Error ? err.message : String(err);
|
|
93
90
|
// If the branch already exists (e.g. previous run), try adding worktree using the existing branch.
|
|
94
91
|
if (msg.includes("already exists") || msg.includes("is already checked out")) {
|