@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,234 @@
|
|
|
1
|
+
import type { AgentEvent } from "@mariozechner/pi-agent-core";
|
|
2
|
+
|
|
3
|
+
// ── Transcript types ──
|
|
4
|
+
|
|
5
|
+
export type TranscriptEntry =
|
|
6
|
+
| { kind: "text"; text: string; timestamp: number }
|
|
7
|
+
| { kind: "tool_start"; toolName: string; timestamp: number }
|
|
8
|
+
| { kind: "tool_end"; toolName: string; durationMs: number; timestamp: number }
|
|
9
|
+
| { kind: "turn_end"; turnNumber: number; tokens: number; timestamp: number };
|
|
10
|
+
|
|
11
|
+
const MAX_TRANSCRIPT = 200;
|
|
12
|
+
|
|
13
|
+
export class TranscriptLog {
|
|
14
|
+
private entries: TranscriptEntry[] = [];
|
|
15
|
+
|
|
16
|
+
push(entry: TranscriptEntry): void {
|
|
17
|
+
this.entries.push(entry);
|
|
18
|
+
if (this.entries.length > MAX_TRANSCRIPT) {
|
|
19
|
+
this.entries.splice(0, this.entries.length - MAX_TRANSCRIPT);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
getEntries(): readonly TranscriptEntry[] {
|
|
24
|
+
return this.entries;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get length(): number {
|
|
28
|
+
return this.entries.length;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
reset(): void {
|
|
32
|
+
this.entries = [];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class TranscriptTracker {
|
|
37
|
+
private logs = new Map<string, TranscriptLog>();
|
|
38
|
+
private toolStarts = new Map<string, Map<string, number>>(); // name -> toolCallId -> startTimestamp
|
|
39
|
+
private pendingText = new Map<string, string>(); // name -> accumulated text
|
|
40
|
+
private turnCounts = new Map<string, number>();
|
|
41
|
+
private lastTokens = new Map<string, number>(); // name -> tokens from last message_end
|
|
42
|
+
|
|
43
|
+
handleEvent(name: string, ev: AgentEvent): void {
|
|
44
|
+
const log = this.getOrCreate(name);
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
|
|
47
|
+
if (ev.type === "message_update") {
|
|
48
|
+
const ame = ev.assistantMessageEvent;
|
|
49
|
+
if (ame.type === "text_delta") {
|
|
50
|
+
const cur = this.pendingText.get(name) ?? "";
|
|
51
|
+
this.pendingText.set(name, cur + ame.delta);
|
|
52
|
+
// Flush complete lines
|
|
53
|
+
this.flushText(name, log, now, false);
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (ev.type === "tool_execution_start") {
|
|
59
|
+
// Flush any pending text before a tool starts
|
|
60
|
+
this.flushText(name, log, now, true);
|
|
61
|
+
const starts = this.toolStarts.get(name) ?? new Map<string, number>();
|
|
62
|
+
starts.set(ev.toolCallId, now);
|
|
63
|
+
this.toolStarts.set(name, starts);
|
|
64
|
+
log.push({ kind: "tool_start", toolName: ev.toolName, timestamp: now });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (ev.type === "tool_execution_end") {
|
|
69
|
+
const starts = this.toolStarts.get(name);
|
|
70
|
+
const startTs = starts?.get(ev.toolCallId);
|
|
71
|
+
const durationMs = startTs === undefined ? 0 : now - startTs;
|
|
72
|
+
starts?.delete(ev.toolCallId);
|
|
73
|
+
log.push({ kind: "tool_end", toolName: ev.toolName, durationMs, timestamp: now });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (ev.type === "message_end") {
|
|
78
|
+
// Capture tokens for use in the turn_end entry
|
|
79
|
+
const msg: unknown = ev.message;
|
|
80
|
+
if (isRecord(msg)) {
|
|
81
|
+
const usage = msg.usage;
|
|
82
|
+
if (isRecord(usage) && typeof usage.totalTokens === "number") {
|
|
83
|
+
this.lastTokens.set(name, (this.lastTokens.get(name) ?? 0) + usage.totalTokens);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (ev.type === "agent_end") {
|
|
90
|
+
// Flush remaining text
|
|
91
|
+
this.flushText(name, log, now, true);
|
|
92
|
+
const turn = (this.turnCounts.get(name) ?? 0) + 1;
|
|
93
|
+
this.turnCounts.set(name, turn);
|
|
94
|
+
const tokens = this.lastTokens.get(name) ?? 0;
|
|
95
|
+
log.push({ kind: "turn_end", turnNumber: turn, tokens, timestamp: now });
|
|
96
|
+
this.lastTokens.set(name, 0);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
get(name: string): TranscriptLog {
|
|
102
|
+
return this.logs.get(name) ?? new TranscriptLog();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
reset(name: string): void {
|
|
106
|
+
this.logs.delete(name);
|
|
107
|
+
this.toolStarts.delete(name);
|
|
108
|
+
this.pendingText.delete(name);
|
|
109
|
+
this.turnCounts.delete(name);
|
|
110
|
+
this.lastTokens.delete(name);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private getOrCreate(name: string): TranscriptLog {
|
|
114
|
+
const existing = this.logs.get(name);
|
|
115
|
+
if (existing) return existing;
|
|
116
|
+
const created = new TranscriptLog();
|
|
117
|
+
this.logs.set(name, created);
|
|
118
|
+
return created;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private flushText(name: string, log: TranscriptLog, timestamp: number, force: boolean): void {
|
|
122
|
+
const buf = this.pendingText.get(name);
|
|
123
|
+
if (!buf) return;
|
|
124
|
+
|
|
125
|
+
// Split into lines; keep the last incomplete line unless forced
|
|
126
|
+
const parts = buf.split("\n");
|
|
127
|
+
if (force) {
|
|
128
|
+
// Flush everything
|
|
129
|
+
for (const part of parts) {
|
|
130
|
+
const trimmed = part.trimEnd();
|
|
131
|
+
if (trimmed) log.push({ kind: "text", text: trimmed, timestamp });
|
|
132
|
+
}
|
|
133
|
+
this.pendingText.delete(name);
|
|
134
|
+
} else if (parts.length > 1) {
|
|
135
|
+
// Flush all complete lines, keep the last (potentially incomplete) part
|
|
136
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
137
|
+
const part = parts[i];
|
|
138
|
+
if (part === undefined) continue;
|
|
139
|
+
const trimmed = part.trimEnd();
|
|
140
|
+
if (trimmed) log.push({ kind: "text", text: trimmed, timestamp });
|
|
141
|
+
}
|
|
142
|
+
this.pendingText.set(name, parts[parts.length - 1] ?? "");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Activity types ──
|
|
148
|
+
|
|
149
|
+
type TrackedEventType = "tool_execution_start" | "tool_execution_end" | "agent_end" | "message_end";
|
|
150
|
+
|
|
151
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
152
|
+
return typeof v === "object" && v !== null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface TeammateActivity {
|
|
156
|
+
toolUseCount: number;
|
|
157
|
+
currentToolName: string | null;
|
|
158
|
+
lastToolName: string | null;
|
|
159
|
+
turnCount: number;
|
|
160
|
+
totalTokens: number;
|
|
161
|
+
recentEvents: Array<{ type: TrackedEventType; toolName?: string; timestamp: number }>;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const MAX_RECENT = 10;
|
|
165
|
+
|
|
166
|
+
function emptyActivity(): TeammateActivity {
|
|
167
|
+
return {
|
|
168
|
+
toolUseCount: 0,
|
|
169
|
+
currentToolName: null,
|
|
170
|
+
lastToolName: null,
|
|
171
|
+
turnCount: 0,
|
|
172
|
+
totalTokens: 0,
|
|
173
|
+
recentEvents: [],
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export class ActivityTracker {
|
|
178
|
+
private data = new Map<string, TeammateActivity>();
|
|
179
|
+
|
|
180
|
+
handleEvent(name: string, ev: AgentEvent): void {
|
|
181
|
+
const a = this.getOrCreate(name);
|
|
182
|
+
const now = Date.now();
|
|
183
|
+
|
|
184
|
+
if (ev.type === "tool_execution_start") {
|
|
185
|
+
a.currentToolName = ev.toolName;
|
|
186
|
+
a.recentEvents.push({ type: ev.type, toolName: ev.toolName, timestamp: now });
|
|
187
|
+
if (a.recentEvents.length > MAX_RECENT) a.recentEvents.shift();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (ev.type === "tool_execution_end") {
|
|
192
|
+
const toolName = a.currentToolName ?? ev.toolName;
|
|
193
|
+
a.toolUseCount++;
|
|
194
|
+
a.lastToolName = toolName;
|
|
195
|
+
a.currentToolName = null;
|
|
196
|
+
a.recentEvents.push({ type: ev.type, toolName, timestamp: now });
|
|
197
|
+
if (a.recentEvents.length > MAX_RECENT) a.recentEvents.shift();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (ev.type === "agent_end") {
|
|
202
|
+
a.turnCount++;
|
|
203
|
+
a.recentEvents.push({ type: ev.type, timestamp: now });
|
|
204
|
+
if (a.recentEvents.length > MAX_RECENT) a.recentEvents.shift();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (ev.type === "message_end") {
|
|
209
|
+
const msg: unknown = ev.message;
|
|
210
|
+
if (!isRecord(msg)) return;
|
|
211
|
+
const usage = msg.usage;
|
|
212
|
+
if (!isRecord(usage)) return;
|
|
213
|
+
const totalTokens = usage.totalTokens;
|
|
214
|
+
if (typeof totalTokens === "number") a.totalTokens += totalTokens;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
get(name: string): TeammateActivity {
|
|
219
|
+
return this.data.get(name) ?? emptyActivity();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
reset(name: string): void {
|
|
223
|
+
this.data.delete(name);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private getOrCreate(name: string): TeammateActivity {
|
|
227
|
+
const existing = this.data.get(name);
|
|
228
|
+
if (existing) return existing;
|
|
229
|
+
|
|
230
|
+
const created = emptyActivity();
|
|
231
|
+
this.data.set(name, created);
|
|
232
|
+
return created;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -4,6 +4,10 @@ function sleep(ms: number): Promise<void> {
|
|
|
4
4
|
return new Promise((r) => setTimeout(r, ms));
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
|
|
8
|
+
return typeof err === "object" && err !== null && "code" in err;
|
|
9
|
+
}
|
|
10
|
+
|
|
7
11
|
export interface LockOptions {
|
|
8
12
|
/** How long to wait to acquire the lock before failing. */
|
|
9
13
|
timeoutMs?: number;
|
|
@@ -18,10 +22,12 @@ export interface LockOptions {
|
|
|
18
22
|
export async function withLock<T>(lockFilePath: string, fn: () => Promise<T>, opts: LockOptions = {}): Promise<T> {
|
|
19
23
|
const timeoutMs = opts.timeoutMs ?? 10_000;
|
|
20
24
|
const staleMs = opts.staleMs ?? 60_000;
|
|
21
|
-
const
|
|
25
|
+
const basePollMs = opts.pollMs ?? 50;
|
|
26
|
+
const maxPollMs = Math.max(basePollMs, 1_000);
|
|
22
27
|
const start = Date.now();
|
|
23
28
|
|
|
24
29
|
let fd: number | null = null;
|
|
30
|
+
let attempt = 0;
|
|
25
31
|
|
|
26
32
|
while (fd === null) {
|
|
27
33
|
try {
|
|
@@ -32,8 +38,8 @@ export async function withLock<T>(lockFilePath: string, fn: () => Promise<T>, op
|
|
|
32
38
|
label: opts.label,
|
|
33
39
|
};
|
|
34
40
|
fs.writeFileSync(fd, JSON.stringify(payload));
|
|
35
|
-
} catch (err:
|
|
36
|
-
if (err
|
|
41
|
+
} catch (err: unknown) {
|
|
42
|
+
if (!isErrnoException(err) || err.code !== "EEXIST") throw err;
|
|
37
43
|
|
|
38
44
|
// Stale lock handling
|
|
39
45
|
try {
|
|
@@ -41,16 +47,26 @@ export async function withLock<T>(lockFilePath: string, fn: () => Promise<T>, op
|
|
|
41
47
|
const age = Date.now() - st.mtimeMs;
|
|
42
48
|
if (age > staleMs) {
|
|
43
49
|
fs.unlinkSync(lockFilePath);
|
|
50
|
+
attempt = 0;
|
|
44
51
|
continue;
|
|
45
52
|
}
|
|
46
53
|
} catch {
|
|
47
54
|
// ignore: stat/unlink failures fall through to wait
|
|
48
55
|
}
|
|
49
56
|
|
|
50
|
-
|
|
57
|
+
const elapsedMs = Date.now() - start;
|
|
58
|
+
if (elapsedMs > timeoutMs) {
|
|
51
59
|
throw new Error(`Timeout acquiring lock: ${lockFilePath}`);
|
|
52
60
|
}
|
|
53
|
-
|
|
61
|
+
|
|
62
|
+
attempt += 1;
|
|
63
|
+
const expBackoff = Math.min(maxPollMs, basePollMs * 2 ** Math.min(attempt, 6));
|
|
64
|
+
const jitterFactor = 0.5 + Math.random(); // [0.5, 1.5)
|
|
65
|
+
const jitteredBackoff = Math.min(maxPollMs, Math.round(expBackoff * jitterFactor));
|
|
66
|
+
|
|
67
|
+
const remainingMs = timeoutMs - elapsedMs;
|
|
68
|
+
const sleepMs = Math.max(1, Math.min(remainingMs, jitteredBackoff));
|
|
69
|
+
await sleep(sleepMs);
|
|
54
70
|
}
|
|
55
71
|
}
|
|
56
72
|
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { popUnreadMessages, writeToMailbox } from "./mailbox.js";
|
|
3
|
+
import { sanitizeName } from "./names.js";
|
|
4
|
+
import {
|
|
5
|
+
TEAM_MAILBOX_NS,
|
|
6
|
+
isIdleNotification,
|
|
7
|
+
isPeerDmSent,
|
|
8
|
+
isPlanApprovalRequest,
|
|
9
|
+
isShutdownApproved,
|
|
10
|
+
isShutdownRejected,
|
|
11
|
+
} from "./protocol.js";
|
|
12
|
+
import { ensureTeamConfig, setMemberStatus, upsertMember } from "./team-config.js";
|
|
13
|
+
|
|
14
|
+
import type { TeamsStyle } from "./teams-style.js";
|
|
15
|
+
import { formatMemberDisplayName, getTeamsStrings } from "./teams-style.js";
|
|
16
|
+
|
|
17
|
+
export async function pollLeaderInbox(opts: {
|
|
18
|
+
ctx: ExtensionContext;
|
|
19
|
+
teamId: string;
|
|
20
|
+
teamDir: string;
|
|
21
|
+
taskListId: string;
|
|
22
|
+
leadName: string;
|
|
23
|
+
style: TeamsStyle;
|
|
24
|
+
pendingPlanApprovals: Map<string, { requestId: string; name: string; taskId?: string }>;
|
|
25
|
+
}): Promise<void> {
|
|
26
|
+
const { ctx, teamId, teamDir, taskListId, leadName, style, pendingPlanApprovals } = opts;
|
|
27
|
+
const strings = getTeamsStrings(style);
|
|
28
|
+
|
|
29
|
+
let msgs: Awaited<ReturnType<typeof popUnreadMessages>>;
|
|
30
|
+
try {
|
|
31
|
+
msgs = await popUnreadMessages(teamDir, TEAM_MAILBOX_NS, leadName);
|
|
32
|
+
} catch (err: unknown) {
|
|
33
|
+
ctx.ui.notify(err instanceof Error ? err.message : String(err), "warning");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (!msgs.length) return;
|
|
37
|
+
|
|
38
|
+
for (const m of msgs) {
|
|
39
|
+
const approved = isShutdownApproved(m.text);
|
|
40
|
+
if (approved) {
|
|
41
|
+
const name = sanitizeName(approved.from);
|
|
42
|
+
const cfg = await ensureTeamConfig(teamDir, {
|
|
43
|
+
teamId,
|
|
44
|
+
taskListId,
|
|
45
|
+
leadName,
|
|
46
|
+
style,
|
|
47
|
+
});
|
|
48
|
+
if (!cfg.members.some((mm) => mm.name === name)) {
|
|
49
|
+
await upsertMember(teamDir, { name, role: "worker", status: "offline" });
|
|
50
|
+
}
|
|
51
|
+
await setMemberStatus(teamDir, name, "offline", {
|
|
52
|
+
lastSeenAt: approved.timestamp,
|
|
53
|
+
meta: {
|
|
54
|
+
shutdownApprovedRequestId: approved.requestId,
|
|
55
|
+
shutdownApprovedAt: approved.timestamp ?? new Date().toISOString(),
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
ctx.ui.notify(`${formatMemberDisplayName(style, name)} shut down`, "info");
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const rejected = isShutdownRejected(m.text);
|
|
63
|
+
if (rejected) {
|
|
64
|
+
const name = sanitizeName(rejected.from);
|
|
65
|
+
await setMemberStatus(teamDir, name, "online", {
|
|
66
|
+
lastSeenAt: rejected.timestamp,
|
|
67
|
+
meta: {
|
|
68
|
+
shutdownRejectedAt: rejected.timestamp ?? new Date().toISOString(),
|
|
69
|
+
shutdownRejectedReason: rejected.reason,
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
ctx.ui.notify(`${formatMemberDisplayName(style, name)} refused shutdown: ${rejected.reason}`, "warning");
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const planReq = isPlanApprovalRequest(m.text);
|
|
77
|
+
if (planReq) {
|
|
78
|
+
const name = sanitizeName(planReq.from);
|
|
79
|
+
const preview = planReq.plan.length > 500 ? planReq.plan.slice(0, 500) + "..." : planReq.plan;
|
|
80
|
+
ctx.ui.notify(`${formatMemberDisplayName(style, name)} requests plan approval:\n${preview}`, "info");
|
|
81
|
+
pendingPlanApprovals.set(name, {
|
|
82
|
+
requestId: planReq.requestId,
|
|
83
|
+
name,
|
|
84
|
+
taskId: planReq.taskId,
|
|
85
|
+
});
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const peerDm = isPeerDmSent(m.text);
|
|
90
|
+
if (peerDm) {
|
|
91
|
+
ctx.ui.notify(`${peerDm.from} → ${peerDm.to}: ${peerDm.summary}`, "info");
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const idle = isIdleNotification(m.text);
|
|
96
|
+
if (idle) {
|
|
97
|
+
const name = sanitizeName(idle.from);
|
|
98
|
+
if (idle.failureReason) {
|
|
99
|
+
const cfg = await ensureTeamConfig(teamDir, {
|
|
100
|
+
teamId,
|
|
101
|
+
taskListId,
|
|
102
|
+
leadName,
|
|
103
|
+
style,
|
|
104
|
+
});
|
|
105
|
+
if (!cfg.members.some((mm) => mm.name === name)) {
|
|
106
|
+
await upsertMember(teamDir, { name, role: "worker", status: "offline" });
|
|
107
|
+
}
|
|
108
|
+
await setMemberStatus(teamDir, name, "offline", {
|
|
109
|
+
lastSeenAt: idle.timestamp,
|
|
110
|
+
meta: { offlineReason: idle.failureReason },
|
|
111
|
+
});
|
|
112
|
+
ctx.ui.notify(`${name} went offline (${idle.failureReason})`, "warning");
|
|
113
|
+
} else {
|
|
114
|
+
const desiredSessionName = `pi agent teams - ${strings.memberTitle.toLowerCase()} ${name}`;
|
|
115
|
+
|
|
116
|
+
const cfg = await ensureTeamConfig(teamDir, {
|
|
117
|
+
teamId,
|
|
118
|
+
taskListId,
|
|
119
|
+
leadName,
|
|
120
|
+
style,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const member = cfg.members.find((mm) => mm.name === name);
|
|
124
|
+
const existingSessionNameRaw = member?.meta?.["sessionName"];
|
|
125
|
+
const existingSessionName = typeof existingSessionNameRaw === "string" ? existingSessionNameRaw : undefined;
|
|
126
|
+
const shouldSendName = existingSessionName !== desiredSessionName;
|
|
127
|
+
|
|
128
|
+
if (!member) {
|
|
129
|
+
// Manual tmux worker: learn from idle notifications.
|
|
130
|
+
await upsertMember(teamDir, {
|
|
131
|
+
name,
|
|
132
|
+
role: "worker",
|
|
133
|
+
status: "online",
|
|
134
|
+
lastSeenAt: idle.timestamp,
|
|
135
|
+
meta: { sessionName: desiredSessionName },
|
|
136
|
+
});
|
|
137
|
+
} else {
|
|
138
|
+
await setMemberStatus(teamDir, name, "online", {
|
|
139
|
+
lastSeenAt: idle.timestamp,
|
|
140
|
+
meta: { sessionName: desiredSessionName },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (shouldSendName) {
|
|
145
|
+
try {
|
|
146
|
+
const ts = new Date().toISOString();
|
|
147
|
+
await writeToMailbox(teamDir, TEAM_MAILBOX_NS, name, {
|
|
148
|
+
from: leadName,
|
|
149
|
+
text: JSON.stringify({
|
|
150
|
+
type: "set_session_name",
|
|
151
|
+
name: desiredSessionName,
|
|
152
|
+
from: leadName,
|
|
153
|
+
timestamp: ts,
|
|
154
|
+
}),
|
|
155
|
+
timestamp: ts,
|
|
156
|
+
});
|
|
157
|
+
} catch {
|
|
158
|
+
// ignore
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (idle.completedTaskId && idle.completedStatus === "failed") {
|
|
163
|
+
ctx.ui.notify(`${name} aborted task #${idle.completedTaskId}`, "warning");
|
|
164
|
+
} else if (idle.completedTaskId) {
|
|
165
|
+
ctx.ui.notify(`${name} is idle task #${idle.completedTaskId}`, "info");
|
|
166
|
+
} else {
|
|
167
|
+
ctx.ui.notify(`${name} is idle`, "info");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
ctx.ui.notify(`Message from ${m.from}: ${m.text}`, "info");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { sanitizeName } from "./names.js";
|
|
3
|
+
import { getTeamDir, getTeamsRootDir } from "./paths.js";
|
|
4
|
+
import type { TeammateRpc } from "./teammate-rpc.js";
|
|
5
|
+
import type { TeamConfig, TeamMember } from "./team-config.js";
|
|
6
|
+
import type { TeamsStyle } from "./teams-style.js";
|
|
7
|
+
import { formatMemberDisplayName, getTeamsStrings } from "./teams-style.js";
|
|
8
|
+
|
|
9
|
+
export async function handleTeamListCommand(opts: {
|
|
10
|
+
ctx: ExtensionCommandContext;
|
|
11
|
+
teammates: Map<string, TeammateRpc>;
|
|
12
|
+
getTeamConfig: () => TeamConfig | null;
|
|
13
|
+
style: TeamsStyle;
|
|
14
|
+
refreshTasks: () => Promise<void>;
|
|
15
|
+
renderWidget: () => void;
|
|
16
|
+
}): Promise<void> {
|
|
17
|
+
const { ctx, teammates, getTeamConfig, style, refreshTasks, renderWidget } = opts;
|
|
18
|
+
const strings = getTeamsStrings(style);
|
|
19
|
+
|
|
20
|
+
await refreshTasks();
|
|
21
|
+
|
|
22
|
+
const teamConfig = getTeamConfig();
|
|
23
|
+
const cfgWorkers = (teamConfig?.members ?? []).filter((m) => m.role === "worker");
|
|
24
|
+
const cfgByName = new Map<string, TeamMember>();
|
|
25
|
+
for (const m of cfgWorkers) cfgByName.set(m.name, m);
|
|
26
|
+
|
|
27
|
+
const names = new Set<string>();
|
|
28
|
+
for (const name of teammates.keys()) names.add(name);
|
|
29
|
+
for (const name of cfgByName.keys()) names.add(name);
|
|
30
|
+
|
|
31
|
+
if (names.size === 0) {
|
|
32
|
+
ctx.ui.notify(`No ${strings.memberTitle.toLowerCase()}s`, "info");
|
|
33
|
+
renderWidget();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const lines: string[] = [];
|
|
38
|
+
for (const name of Array.from(names).sort()) {
|
|
39
|
+
const rpc = teammates.get(name);
|
|
40
|
+
const cfg = cfgByName.get(name);
|
|
41
|
+
const status = rpc ? rpc.status : cfg?.status ?? "offline";
|
|
42
|
+
const kind = rpc ? "rpc" : cfg ? "manual" : "unknown";
|
|
43
|
+
lines.push(`${formatMemberDisplayName(style, name)}: ${status} (${kind})`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
47
|
+
renderWidget();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function handleTeamIdCommand(opts: {
|
|
51
|
+
ctx: ExtensionCommandContext;
|
|
52
|
+
taskListId: string | null;
|
|
53
|
+
leadName: string;
|
|
54
|
+
style: TeamsStyle;
|
|
55
|
+
}): Promise<void> {
|
|
56
|
+
const { ctx, taskListId, leadName, style } = opts;
|
|
57
|
+
|
|
58
|
+
const teamId = ctx.sessionManager.getSessionId();
|
|
59
|
+
const effectiveTlId = taskListId ?? teamId;
|
|
60
|
+
const teamsRoot = getTeamsRootDir();
|
|
61
|
+
const teamDir = getTeamDir(teamId);
|
|
62
|
+
|
|
63
|
+
ctx.ui.notify(
|
|
64
|
+
[
|
|
65
|
+
`teamId: ${teamId}`,
|
|
66
|
+
`taskListId: ${effectiveTlId}`,
|
|
67
|
+
`leadName: ${leadName}`,
|
|
68
|
+
`style: ${style}`,
|
|
69
|
+
`teamsRoot: ${teamsRoot}`,
|
|
70
|
+
`teamDir: ${teamDir}`,
|
|
71
|
+
].join("\n"),
|
|
72
|
+
"info",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function handleTeamEnvCommand(opts: {
|
|
77
|
+
ctx: ExtensionCommandContext;
|
|
78
|
+
rest: string[];
|
|
79
|
+
taskListId: string | null;
|
|
80
|
+
leadName: string;
|
|
81
|
+
style: TeamsStyle;
|
|
82
|
+
getTeamsExtensionEntryPath: () => string | null;
|
|
83
|
+
shellQuote: (v: string) => string;
|
|
84
|
+
}): Promise<void> {
|
|
85
|
+
const { ctx, rest, taskListId, leadName, style, getTeamsExtensionEntryPath, shellQuote } = opts;
|
|
86
|
+
|
|
87
|
+
const nameRaw = rest[0];
|
|
88
|
+
if (!nameRaw) {
|
|
89
|
+
ctx.ui.notify("Usage: /team env <name>", "error");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const name = sanitizeName(nameRaw);
|
|
94
|
+
const teamId = ctx.sessionManager.getSessionId();
|
|
95
|
+
const effectiveTlId = taskListId ?? teamId;
|
|
96
|
+
const teamsRoot = getTeamsRootDir();
|
|
97
|
+
const teamDir = getTeamDir(teamId);
|
|
98
|
+
const autoClaim = (process.env.PI_TEAMS_DEFAULT_AUTO_CLAIM ?? "1") === "1" ? "1" : "0";
|
|
99
|
+
|
|
100
|
+
const teamsEntry = getTeamsExtensionEntryPath();
|
|
101
|
+
const piCmd = teamsEntry ? `pi --no-extensions -e ${shellQuote(teamsEntry)}` : "pi";
|
|
102
|
+
|
|
103
|
+
const env: Record<string, string> = {
|
|
104
|
+
PI_TEAMS_ROOT_DIR: teamsRoot,
|
|
105
|
+
PI_TEAMS_WORKER: "1",
|
|
106
|
+
PI_TEAMS_TEAM_ID: teamId,
|
|
107
|
+
PI_TEAMS_TASK_LIST_ID: effectiveTlId,
|
|
108
|
+
PI_TEAMS_AGENT_NAME: name,
|
|
109
|
+
PI_TEAMS_LEAD_NAME: leadName,
|
|
110
|
+
PI_TEAMS_STYLE: style,
|
|
111
|
+
PI_TEAMS_AUTO_CLAIM: autoClaim,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const exportLines = Object.entries(env)
|
|
115
|
+
.map(([k, v]) => `export ${k}=${shellQuote(v)}`)
|
|
116
|
+
.join("\n");
|
|
117
|
+
|
|
118
|
+
const oneLiner = Object.entries(env)
|
|
119
|
+
.map(([k, v]) => `${k}=${shellQuote(v)}`)
|
|
120
|
+
.join(" ")
|
|
121
|
+
.concat(` ${piCmd}`);
|
|
122
|
+
|
|
123
|
+
ctx.ui.notify(
|
|
124
|
+
[
|
|
125
|
+
`teamId: ${teamId}`,
|
|
126
|
+
`taskListId: ${effectiveTlId}`,
|
|
127
|
+
`leadName: ${leadName}`,
|
|
128
|
+
`teamsRoot: ${teamsRoot}`,
|
|
129
|
+
`teamDir: ${teamDir}`,
|
|
130
|
+
"",
|
|
131
|
+
"Env (copy/paste):",
|
|
132
|
+
exportLines,
|
|
133
|
+
"",
|
|
134
|
+
"Run:",
|
|
135
|
+
oneLiner,
|
|
136
|
+
].join("\n"),
|
|
137
|
+
"info",
|
|
138
|
+
);
|
|
139
|
+
}
|