@tmustier/pi-agent-teams 0.5.3 → 0.5.5
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/CHANGELOG.md +12 -0
- package/extensions/teams/activity-tracker.ts +1 -129
- package/extensions/teams/index.ts +1 -1
- package/extensions/teams/leader-attach-commands.ts +1 -1
- package/extensions/teams/leader-inbox.ts +30 -24
- package/extensions/teams/leader-info-commands.ts +1 -1
- package/extensions/teams/leader-lifecycle-commands.ts +1 -1
- package/extensions/teams/leader-messaging-commands.ts +1 -1
- package/extensions/teams/leader-plan-commands.ts +1 -1
- package/extensions/teams/leader-spawn-command.ts +2 -2
- package/extensions/teams/leader-task-commands.ts +1 -1
- package/extensions/teams/leader-team-command.ts +1 -1
- package/extensions/teams/leader-teams-tool.ts +16 -10
- package/extensions/teams/leader.ts +12 -76
- package/extensions/teams/paths.ts +1 -1
- package/extensions/teams/session-branching.ts +133 -0
- package/extensions/teams/session-parent.ts +1 -1
- package/extensions/teams/spawn-types.ts +2 -2
- package/extensions/teams/teammate-rpc.ts +1 -1
- package/extensions/teams/teams-panel.ts +2 -2
- package/extensions/teams/teams-ui-shared.ts +2 -2
- package/extensions/teams/teams-widget.ts +3 -3
- package/extensions/teams/worker.ts +2 -2
- package/package.json +11 -10
- package/scripts/integration-branch-context-test.mts +299 -0
- package/scripts/smoke-test.mts +283 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.5.5] - 2026-05-07
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
- Update Pi package imports, peer dependencies, and development dependencies to the new `@earendil-works` namespace.
|
|
7
|
+
|
|
8
|
+
## 0.5.4
|
|
9
|
+
|
|
10
|
+
### Fixes
|
|
11
|
+
|
|
12
|
+
- **Clean branch-mode worker sessions** — teammates spawned with `contextMode=branch` now branch from the last stable pre-turn boundary, replay the active user request, preserve compaction entries, and materialize user-only fallbacks. This prevents workers from inheriting the leader's in-progress tool-use turn or reopening as blank sessions. (#35, #36)
|
|
13
|
+
- **Accurate quality-gate completion notices** — leader completion and batch-complete messages now mention running quality gates only when hooks are actually enabled, instead of inferring that from the presence of an internal enqueue callback. (#37)
|
|
14
|
+
|
|
3
15
|
## 0.5.3
|
|
4
16
|
|
|
5
17
|
### Fixes
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AgentEvent } from "@
|
|
1
|
+
import type { AgentEvent } from "@earendil-works/pi-agent-core";
|
|
2
2
|
|
|
3
3
|
// ── Helpers ──
|
|
4
4
|
|
|
@@ -15,134 +15,6 @@ export type TranscriptEntry =
|
|
|
15
15
|
| { kind: "turn_end"; turnNumber: number; tokens: number; timestamp: number };
|
|
16
16
|
|
|
17
17
|
const MAX_TRANSCRIPT = 200;
|
|
18
|
-
const MAX_SUMMARY_LENGTH = 120;
|
|
19
|
-
|
|
20
|
-
// ── Tool content summarization ──
|
|
21
|
-
|
|
22
|
-
function truncateSummary(text: string): string {
|
|
23
|
-
if (text.length <= MAX_SUMMARY_LENGTH) return text;
|
|
24
|
-
return `${text.slice(0, MAX_SUMMARY_LENGTH - 1)}…`;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function summarizeToolArgs(toolName: string, args: unknown): string {
|
|
28
|
-
if (!isRecord(args)) return "";
|
|
29
|
-
const key = toolName.toLowerCase();
|
|
30
|
-
|
|
31
|
-
if (key === "read" || key === "write") {
|
|
32
|
-
const path = typeof args.path === "string" ? args.path : null;
|
|
33
|
-
if (!path) return "";
|
|
34
|
-
return truncateSummary(path);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
if (key === "edit") {
|
|
38
|
-
const path = typeof args.path === "string" ? args.path : null;
|
|
39
|
-
if (!path) return "";
|
|
40
|
-
return truncateSummary(path);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (key === "bash") {
|
|
44
|
-
const cmd = typeof args.command === "string" ? args.command : null;
|
|
45
|
-
if (!cmd) return "";
|
|
46
|
-
return truncateSummary(cmd.replace(/\s+/g, " ").trim());
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
if (key === "grep" || key === "glob") {
|
|
50
|
-
const pattern = typeof args.pattern === "string" ? args.pattern : null;
|
|
51
|
-
const path = typeof args.path === "string" ? args.path : null;
|
|
52
|
-
const parts: string[] = [];
|
|
53
|
-
if (pattern) parts.push(pattern);
|
|
54
|
-
if (path) parts.push(`in ${path}`);
|
|
55
|
-
return truncateSummary(parts.join(" "));
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
if (key === "webfetch" || key === "websearch") {
|
|
59
|
-
const url = typeof args.url === "string" ? args.url : null;
|
|
60
|
-
const query = typeof args.query === "string" ? args.query : null;
|
|
61
|
-
return truncateSummary(url ?? query ?? "");
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (key === "team_message") {
|
|
65
|
-
const recipient = typeof args.recipient === "string" ? args.recipient : null;
|
|
66
|
-
const message = typeof args.message === "string" ? args.message : null;
|
|
67
|
-
if (recipient && message) return truncateSummary(`→ ${recipient}: ${message.replace(/\s+/g, " ").trim()}`);
|
|
68
|
-
if (message) return truncateSummary(message.replace(/\s+/g, " ").trim());
|
|
69
|
-
return recipient ? truncateSummary(`→ ${recipient}`) : "";
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
if (key === "task" || key === "teams") {
|
|
73
|
-
const action = typeof args.action === "string" ? args.action : null;
|
|
74
|
-
return action ? truncateSummary(action) : "";
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Fallback: try to find a meaningful first-string arg
|
|
78
|
-
for (const v of Object.values(args)) {
|
|
79
|
-
if (typeof v === "string" && v.length > 0) return truncateSummary(v);
|
|
80
|
-
}
|
|
81
|
-
return "";
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Extract the first text content from a ToolResultMessage-shaped result.
|
|
86
|
-
* The agent-loop emits `{ content: [{type: "text", text: "..."}], ... }`.
|
|
87
|
-
*/
|
|
88
|
-
function extractContentText(result: Record<string, unknown>): string | null {
|
|
89
|
-
const content = result.content;
|
|
90
|
-
if (!Array.isArray(content)) return null;
|
|
91
|
-
for (const item of content) {
|
|
92
|
-
if (isRecord(item) && item.type === "text" && typeof item.text === "string") {
|
|
93
|
-
return item.text;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
return null;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function summarizeToolResult(toolName: string, result: unknown, isError: boolean): string {
|
|
100
|
-
if (isError) {
|
|
101
|
-
if (typeof result === "string") return truncateSummary(result.replace(/\s+/g, " ").trim());
|
|
102
|
-
if (isRecord(result)) {
|
|
103
|
-
// Try content array first (ToolResultMessage shape)
|
|
104
|
-
const contentText = extractContentText(result);
|
|
105
|
-
if (contentText !== null) {
|
|
106
|
-
const trimmed = contentText.replace(/\s+/g, " ").trim();
|
|
107
|
-
return trimmed.length > 0 ? truncateSummary(trimmed) : "error";
|
|
108
|
-
}
|
|
109
|
-
const msg = typeof result.message === "string"
|
|
110
|
-
? result.message
|
|
111
|
-
: typeof result.error === "string"
|
|
112
|
-
? result.error
|
|
113
|
-
: null;
|
|
114
|
-
if (msg) return truncateSummary(msg.replace(/\s+/g, " ").trim());
|
|
115
|
-
}
|
|
116
|
-
return "error";
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (typeof result === "string") {
|
|
120
|
-
const compact = result.replace(/\s+/g, " ").trim();
|
|
121
|
-
if (compact.length === 0) return "(empty)";
|
|
122
|
-
return truncateSummary(compact);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (isRecord(result)) {
|
|
126
|
-
// Try content array first (ToolResultMessage shape from agent-loop)
|
|
127
|
-
const contentText = extractContentText(result);
|
|
128
|
-
if (contentText !== null) {
|
|
129
|
-
const compact = contentText.replace(/\s+/g, " ").trim();
|
|
130
|
-
if (compact.length === 0) return "(empty)";
|
|
131
|
-
return truncateSummary(compact);
|
|
132
|
-
}
|
|
133
|
-
// Fallback: check for common structured result shapes
|
|
134
|
-
const status = typeof result.status === "string" ? result.status : null;
|
|
135
|
-
if (status) return truncateSummary(status);
|
|
136
|
-
const output = typeof result.output === "string" ? result.output : null;
|
|
137
|
-
if (output) return truncateSummary(output.replace(/\s+/g, " ").trim());
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
if (Array.isArray(result)) {
|
|
141
|
-
return `${String(result.length)} items`;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return "";
|
|
145
|
-
}
|
|
146
18
|
|
|
147
19
|
export class TranscriptLog {
|
|
148
20
|
private entries: TranscriptEntry[] = [];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionContext } from "@
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { popUnreadMessages, writeToMailbox } from "./mailbox.js";
|
|
3
3
|
import { sanitizeName } from "./names.js";
|
|
4
4
|
import {
|
|
@@ -91,12 +91,14 @@ export async function pollLeaderInbox(opts: {
|
|
|
91
91
|
style: TeamsStyle;
|
|
92
92
|
pendingPlanApprovals: Map<string, { requestId: string; name: string; taskId?: string }>;
|
|
93
93
|
enqueueHook?: (invocation: TeamsHookInvocation) => void;
|
|
94
|
+
hooksEnabled?: boolean;
|
|
94
95
|
sendLeaderLlmMessage?: SendLeaderLlmMessage;
|
|
95
96
|
/** Batch delegation tracker for all-tasks-complete auto-notify. */
|
|
96
97
|
delegationTracker?: DelegationTracker;
|
|
97
98
|
}): Promise<void> {
|
|
98
|
-
const { ctx, teamId, teamDir, taskListId, leadName, style, pendingPlanApprovals, enqueueHook, sendLeaderLlmMessage, delegationTracker } = opts;
|
|
99
|
+
const { ctx, teamId, teamDir, taskListId, leadName, style, pendingPlanApprovals, enqueueHook, hooksEnabled, sendLeaderLlmMessage, delegationTracker } = opts;
|
|
99
100
|
const strings = getTeamsStrings(style);
|
|
101
|
+
const hooksActive = hooksEnabled ?? Boolean(enqueueHook);
|
|
100
102
|
|
|
101
103
|
let msgs: Awaited<ReturnType<typeof popUnreadMessages>>;
|
|
102
104
|
try {
|
|
@@ -173,38 +175,42 @@ export async function pollLeaderInbox(opts: {
|
|
|
173
175
|
const name = sanitizeName(idle.from);
|
|
174
176
|
|
|
175
177
|
// Hook: always emit "idle" (best-effort, non-blocking)
|
|
176
|
-
|
|
177
|
-
enqueueHook?.({
|
|
178
|
-
event: "idle",
|
|
179
|
-
teamId,
|
|
180
|
-
teamDir,
|
|
181
|
-
taskListId,
|
|
182
|
-
style,
|
|
183
|
-
memberName: name,
|
|
184
|
-
timestamp: idle.timestamp,
|
|
185
|
-
completedTask: null,
|
|
186
|
-
});
|
|
187
|
-
} catch {
|
|
188
|
-
// ignore hook enqueue errors
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// Hook: task completion / failure
|
|
192
|
-
if (idle.completedTaskId) {
|
|
193
|
-
const completedTask = await getTask(teamDir, taskListId, idle.completedTaskId);
|
|
178
|
+
if (hooksActive) {
|
|
194
179
|
try {
|
|
195
180
|
enqueueHook?.({
|
|
196
|
-
event: idle
|
|
181
|
+
event: "idle",
|
|
197
182
|
teamId,
|
|
198
183
|
teamDir,
|
|
199
184
|
taskListId,
|
|
200
185
|
style,
|
|
201
186
|
memberName: name,
|
|
202
187
|
timestamp: idle.timestamp,
|
|
203
|
-
completedTask,
|
|
188
|
+
completedTask: null,
|
|
204
189
|
});
|
|
205
190
|
} catch {
|
|
206
191
|
// ignore hook enqueue errors
|
|
207
192
|
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Hook: task completion / failure
|
|
196
|
+
if (idle.completedTaskId) {
|
|
197
|
+
const completedTask = await getTask(teamDir, taskListId, idle.completedTaskId);
|
|
198
|
+
if (hooksActive) {
|
|
199
|
+
try {
|
|
200
|
+
enqueueHook?.({
|
|
201
|
+
event: idle.completedStatus === "failed" ? "task_failed" : "task_completed",
|
|
202
|
+
teamId,
|
|
203
|
+
teamDir,
|
|
204
|
+
taskListId,
|
|
205
|
+
style,
|
|
206
|
+
memberName: name,
|
|
207
|
+
timestamp: idle.timestamp,
|
|
208
|
+
completedTask,
|
|
209
|
+
});
|
|
210
|
+
} catch {
|
|
211
|
+
// ignore hook enqueue errors
|
|
212
|
+
}
|
|
213
|
+
}
|
|
208
214
|
|
|
209
215
|
// Event-driven batch tracking: mark this task done and
|
|
210
216
|
// collect any batches that became fully complete.
|
|
@@ -319,7 +325,7 @@ export async function pollLeaderInbox(opts: {
|
|
|
319
325
|
|
|
320
326
|
if (allDone) {
|
|
321
327
|
lines.push("");
|
|
322
|
-
if (
|
|
328
|
+
if (hooksActive) {
|
|
323
329
|
// Hooks run asynchronously and may reopen tasks or create follow-ups.
|
|
324
330
|
lines.push(`All ${totalTasks} task(s) show completed — quality gates are still running and may change task states.`);
|
|
325
331
|
} else {
|
|
@@ -354,7 +360,7 @@ export async function pollLeaderInbox(opts: {
|
|
|
354
360
|
if (sendLeaderLlmMessage) {
|
|
355
361
|
for (const batch of batchCompletions) {
|
|
356
362
|
const taskRefs = batch.taskIds.map((id) => `#${id}`).join(", ");
|
|
357
|
-
const suffix =
|
|
363
|
+
const suffix = hooksActive
|
|
358
364
|
? "Quality gates are still running and may change task states."
|
|
359
365
|
: "Review the results and continue.";
|
|
360
366
|
const msg = `[Team] All delegated tasks completed (${taskRefs}). ${suffix}`;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionCommandContext } from "@
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { sanitizeName } from "./names.js";
|
|
3
3
|
import { getTeamDir, getTeamsRootDir } from "./paths.js";
|
|
4
4
|
import type { TeammateRpc } from "./teammate-rpc.js";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
-
import type { ExtensionCommandContext, ExtensionContext } from "@
|
|
4
|
+
import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { cleanupTeamDir, gcStaleTeamDirs } from "./cleanup.js";
|
|
6
6
|
import { writeToMailbox } from "./mailbox.js";
|
|
7
7
|
import { sanitizeName } from "./names.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionCommandContext } from "@
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { writeToMailbox } from "./mailbox.js";
|
|
3
3
|
import { sanitizeName } from "./names.js";
|
|
4
4
|
import { getTeamDir } from "./paths.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionCommandContext } from "@
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { writeToMailbox } from "./mailbox.js";
|
|
3
3
|
import { sanitizeName } from "./names.js";
|
|
4
4
|
import { getTeamDir } from "./paths.js";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { ThinkingLevel } from "@
|
|
2
|
-
import type { ExtensionCommandContext } from "@
|
|
1
|
+
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { pickAgentNames, pickNamesFromPool } from "./names.js";
|
|
4
4
|
import type { TeammateRpc } from "./teammate-rpc.js";
|
|
5
5
|
import type { TeamsStyle } from "./teams-style.js";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
|
-
import type { ExtensionCommandContext } from "@
|
|
2
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { writeToMailbox } from "./mailbox.js";
|
|
4
4
|
import { sanitizeName } from "./names.js";
|
|
5
5
|
import { getTeamDir } from "./paths.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionCommandContext, ExtensionContext } from "@
|
|
1
|
+
import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { getTeamDir } from "./paths.js";
|
|
3
3
|
import {
|
|
4
4
|
handleTeamEnvCommand,
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import type { AgentToolResult } from "@
|
|
3
|
-
import { StringEnum } from "@mariozechner/pi-ai";
|
|
2
|
+
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
4
3
|
import { Type, type Static } from "@sinclair/typebox";
|
|
5
|
-
import type { ExtensionAPI } from "@
|
|
4
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
5
|
import { writeToMailbox } from "./mailbox.js";
|
|
7
6
|
import { pickAgentNames, pickNamesFromPool, sanitizeName } from "./names.js";
|
|
8
7
|
import { getTeamDir } from "./paths.js";
|
|
@@ -52,7 +51,14 @@ function describeModelSource(source: TeammateModelSource): string {
|
|
|
52
51
|
return "teammate-default";
|
|
53
52
|
}
|
|
54
53
|
|
|
55
|
-
const
|
|
54
|
+
function stringEnum<const Values extends readonly [string, ...string[]]>(
|
|
55
|
+
values: Values,
|
|
56
|
+
options: Record<string, unknown> = {},
|
|
57
|
+
) {
|
|
58
|
+
return Type.Unsafe<Values[number]>({ type: "string", enum: [...values], ...options });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const TeamsActionSchema = stringEnum(
|
|
56
62
|
[
|
|
57
63
|
"delegate",
|
|
58
64
|
"task_assign",
|
|
@@ -83,30 +89,30 @@ const TeamsActionSchema = StringEnum(
|
|
|
83
89
|
},
|
|
84
90
|
);
|
|
85
91
|
|
|
86
|
-
const TeamsTaskStatusSchema =
|
|
92
|
+
const TeamsTaskStatusSchema = stringEnum(["pending", "in_progress", "completed"] as const, {
|
|
87
93
|
description: "Task status for action=task_set_status.",
|
|
88
94
|
});
|
|
89
95
|
|
|
90
|
-
const TeamsContextModeSchema =
|
|
96
|
+
const TeamsContextModeSchema = stringEnum(["fresh", "branch"] as const, {
|
|
91
97
|
description: "How to initialize comrade session context. 'branch' clones the leader session branch.",
|
|
92
98
|
default: "fresh",
|
|
93
99
|
});
|
|
94
100
|
|
|
95
|
-
const TeamsWorkspaceModeSchema =
|
|
101
|
+
const TeamsWorkspaceModeSchema = stringEnum(["shared", "worktree"] as const, {
|
|
96
102
|
description: "Workspace isolation mode. 'shared' matches Claude Teams; 'worktree' creates a git worktree per comrade.",
|
|
97
103
|
default: "shared",
|
|
98
104
|
});
|
|
99
105
|
|
|
100
|
-
const TeamsThinkingLevelSchema =
|
|
106
|
+
const TeamsThinkingLevelSchema = stringEnum(["off", "minimal", "low", "medium", "high", "xhigh"] as const, {
|
|
101
107
|
description:
|
|
102
108
|
"Thinking level to use for spawned comrades (defaults to the leader's current thinking level when omitted).",
|
|
103
109
|
});
|
|
104
110
|
|
|
105
|
-
const TeamsHookFailureActionSchema =
|
|
111
|
+
const TeamsHookFailureActionSchema = stringEnum(["warn", "followup", "reopen", "reopen_followup"] as const, {
|
|
106
112
|
description: "Hook failure policy for hooks_policy_set.",
|
|
107
113
|
});
|
|
108
114
|
|
|
109
|
-
const TeamsHookFollowupOwnerSchema =
|
|
115
|
+
const TeamsHookFollowupOwnerSchema = stringEnum(["member", "lead", "none"] as const, {
|
|
110
116
|
description: "Follow-up owner policy for hooks_policy_set.",
|
|
111
117
|
});
|
|
112
118
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@
|
|
5
|
-
import { SessionManager } from "@
|
|
4
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { writeToMailbox } from "./mailbox.js";
|
|
7
7
|
import { sanitizeName } from "./names.js";
|
|
8
8
|
import { TEAM_MAILBOX_NS, taskAssignmentPayload } from "./protocol.js";
|
|
@@ -22,6 +22,7 @@ import { getTeamsStyleFromEnv, type TeamsStyle, formatMemberDisplayName, getTeam
|
|
|
22
22
|
import { DelegationTracker, pollLeaderInbox as pollLeaderInboxImpl } from "./leader-inbox.js";
|
|
23
23
|
import {
|
|
24
24
|
getHookBaseName,
|
|
25
|
+
areTeamsHooksEnabled,
|
|
25
26
|
getTeamsHookFailureAction,
|
|
26
27
|
getTeamsHookFollowupOwnerPolicy,
|
|
27
28
|
getTeamsHookMaxReopensPerTask,
|
|
@@ -34,6 +35,7 @@ import {
|
|
|
34
35
|
import { handleTeamCommand } from "./leader-team-command.js";
|
|
35
36
|
import { registerTeamsTool } from "./leader-teams-tool.js";
|
|
36
37
|
import { getParentSessionId, shouldSilenceInheritedParentAttachClaimWarning } from "./session-parent.js";
|
|
38
|
+
import { branchSelectionNote, ensureSessionFileMaterialized, resolveBranchLeafSelection } from "./session-branching.js";
|
|
37
39
|
import type { ContextMode, SpawnTeammateFn, SpawnTeammateResult, WorkspaceMode } from "./spawn-types.js";
|
|
38
40
|
|
|
39
41
|
function getTeamsExtensionEntryPath(): string | null {
|
|
@@ -90,12 +92,17 @@ async function createSessionForTeammate(
|
|
|
90
92
|
|
|
91
93
|
try {
|
|
92
94
|
const sm = SessionManager.open(parentSessionFile, teamSessionsDir);
|
|
93
|
-
const
|
|
95
|
+
const selection = resolveBranchLeafSelection(sm.getBranch(leafId), leafId);
|
|
96
|
+
const branched = sm.createBranchedSession(selection.leafId);
|
|
94
97
|
if (!branched) {
|
|
95
98
|
const fallback = SessionManager.create(ctx.cwd, teamSessionsDir);
|
|
96
99
|
return { sessionFile: fallback.getSessionFile(), note: "branch(failed->fresh)", warnings };
|
|
97
100
|
}
|
|
98
|
-
|
|
101
|
+
if (selection.replayUserMessage) {
|
|
102
|
+
sm.appendMessage(JSON.parse(JSON.stringify(selection.replayUserMessage)) as Parameters<typeof sm.appendMessage>[0]);
|
|
103
|
+
}
|
|
104
|
+
await ensureSessionFileMaterialized(sm, branched);
|
|
105
|
+
return { sessionFile: branched, note: branchSelectionNote(selection), warnings };
|
|
99
106
|
} catch (err) {
|
|
100
107
|
const msg = err instanceof Error ? err.message : String(err);
|
|
101
108
|
if (/Entry .* not found/i.test(msg)) {
|
|
@@ -702,6 +709,7 @@ export function runLeader(pi: ExtensionAPI): void {
|
|
|
702
709
|
style,
|
|
703
710
|
pendingPlanApprovals,
|
|
704
711
|
enqueueHook,
|
|
712
|
+
hooksEnabled: areTeamsHooksEnabled(process.env),
|
|
705
713
|
sendLeaderLlmMessage: (content, options) => {
|
|
706
714
|
pi.sendUserMessage(content, options);
|
|
707
715
|
},
|
|
@@ -778,78 +786,6 @@ export function runLeader(pi: ExtensionAPI): void {
|
|
|
778
786
|
inboxTimer.unref?.();
|
|
779
787
|
});
|
|
780
788
|
|
|
781
|
-
pi.on("session_switch", async (_event, ctx) => {
|
|
782
|
-
const prevTeamId = currentTeamId;
|
|
783
|
-
const prevCwd = currentCtx?.cwd;
|
|
784
|
-
|
|
785
|
-
if (currentCtx) {
|
|
786
|
-
await releaseActiveAttachClaim(currentCtx);
|
|
787
|
-
const strings = getTeamsStrings(style);
|
|
788
|
-
await stopAllTeammates(currentCtx, `The ${strings.teamNoun} is dissolved — leader moved on`);
|
|
789
|
-
}
|
|
790
|
-
stopLoops();
|
|
791
|
-
delegationTracker.clear();
|
|
792
|
-
|
|
793
|
-
// Clean up worktrees from the old session before switching.
|
|
794
|
-
// Only clean up teams this session owns — never attached teams.
|
|
795
|
-
const prevSessionId = currentCtx?.sessionManager.getSessionId();
|
|
796
|
-
if (prevTeamId && prevTeamId === prevSessionId) {
|
|
797
|
-
const teamDir = getTeamDir(prevTeamId);
|
|
798
|
-
try {
|
|
799
|
-
await cleanupWorktrees({ teamDir, teamId: prevTeamId, repoCwd: prevCwd });
|
|
800
|
-
} catch {
|
|
801
|
-
// Best-effort — don't block session switch.
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
currentCtx = ctx;
|
|
806
|
-
currentTeamId = currentCtx.sessionManager.getSessionId();
|
|
807
|
-
inheritedParentTeamId = getParentSessionId(currentCtx.sessionManager);
|
|
808
|
-
// Keep the task list aligned with the active session. If you want a shared namespace,
|
|
809
|
-
// use `/team task use <taskListId>` after switching.
|
|
810
|
-
taskListId = currentTeamId;
|
|
811
|
-
lastAttachClaimHeartbeatMs = 0;
|
|
812
|
-
// Clear any /team done suppression — new session context.
|
|
813
|
-
widgetSuppressed = false;
|
|
814
|
-
autoDoneNotified = false;
|
|
815
|
-
|
|
816
|
-
await ensureTeamConfig(getTeamDir(currentTeamId), {
|
|
817
|
-
teamId: currentTeamId,
|
|
818
|
-
taskListId: taskListId,
|
|
819
|
-
leadName: "team-lead",
|
|
820
|
-
style,
|
|
821
|
-
});
|
|
822
|
-
|
|
823
|
-
await refreshTasks();
|
|
824
|
-
renderWidget();
|
|
825
|
-
|
|
826
|
-
// Restart background refresh/poll loops for the new session.
|
|
827
|
-
refreshTimer = setInterval(async () => {
|
|
828
|
-
if (isStopping) return;
|
|
829
|
-
if (refreshInFlight) return;
|
|
830
|
-
refreshInFlight = true;
|
|
831
|
-
try {
|
|
832
|
-
await heartbeatActiveAttachClaim(ctx);
|
|
833
|
-
await refreshTasks();
|
|
834
|
-
renderWidget();
|
|
835
|
-
} finally {
|
|
836
|
-
refreshInFlight = false;
|
|
837
|
-
}
|
|
838
|
-
}, 1000);
|
|
839
|
-
refreshTimer.unref?.();
|
|
840
|
-
|
|
841
|
-
inboxTimer = setInterval(async () => {
|
|
842
|
-
if (isStopping) return;
|
|
843
|
-
if (inboxInFlight) return;
|
|
844
|
-
inboxInFlight = true;
|
|
845
|
-
try {
|
|
846
|
-
await pollLeaderInbox();
|
|
847
|
-
} finally {
|
|
848
|
-
inboxInFlight = false;
|
|
849
|
-
}
|
|
850
|
-
}, 700);
|
|
851
|
-
inboxTimer.unref?.();
|
|
852
|
-
});
|
|
853
789
|
|
|
854
790
|
pi.on("session_shutdown", async () => {
|
|
855
791
|
if (!currentCtx) return;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { SessionEntry, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
export type BranchLeafSelection = {
|
|
6
|
+
leafId: string;
|
|
7
|
+
adjusted: boolean;
|
|
8
|
+
reason: "requested" | "clean-turn-replay" | "clean-turn-stable" | "clean-turn-user";
|
|
9
|
+
replayUserMessage?: UserMessageLike;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type MessageLike = Record<string, unknown> & { role: string };
|
|
13
|
+
type UserMessageLike = MessageLike & { role: "user"; content: unknown; timestamp: number };
|
|
14
|
+
|
|
15
|
+
type MessageEntryLike = SessionEntry & {
|
|
16
|
+
type: "message";
|
|
17
|
+
message: MessageLike;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
21
|
+
return typeof value === "object" && value !== null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isMessageEntry(entry: SessionEntry): entry is MessageEntryLike {
|
|
25
|
+
if (entry.type !== "message") return false;
|
|
26
|
+
return isRecord(entry.message) && typeof entry.message.role === "string";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isUserMessageEntry(entry: SessionEntry): entry is MessageEntryLike & { message: UserMessageLike } {
|
|
30
|
+
return isMessageEntry(entry) && entry.message.role === "user";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isAssistantToolUseEntry(entry: SessionEntry): entry is MessageEntryLike & { message: MessageLike & { role: "assistant"; stopReason: "toolUse" } } {
|
|
34
|
+
return (
|
|
35
|
+
isMessageEntry(entry) &&
|
|
36
|
+
entry.message.role === "assistant" &&
|
|
37
|
+
typeof entry.message.stopReason === "string" &&
|
|
38
|
+
entry.message.stopReason === "toolUse"
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isStableAssistantEntry(entry: SessionEntry): entry is MessageEntryLike & { message: MessageLike & { role: "assistant" } } {
|
|
43
|
+
return (
|
|
44
|
+
isMessageEntry(entry) &&
|
|
45
|
+
entry.message.role === "assistant" &&
|
|
46
|
+
typeof entry.message.stopReason === "string" &&
|
|
47
|
+
entry.message.stopReason !== "toolUse"
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* When the leader is mid-turn, the current leaf may point into an unfinished
|
|
53
|
+
* assistant/tool-use path. Branching from that leaf causes workers to inherit
|
|
54
|
+
* the leader's in-progress turn instead of a clean conversation context.
|
|
55
|
+
*
|
|
56
|
+
* In that case, branch from the last stable turn boundary instead:
|
|
57
|
+
* - Prefer the latest completed assistant message (persists as a real branched file)
|
|
58
|
+
* - Otherwise fall back to the latest user message
|
|
59
|
+
*/
|
|
60
|
+
export function resolveBranchLeafSelection(path: SessionEntry[], requestedLeafId: string): BranchLeafSelection {
|
|
61
|
+
const lastAssistantIndex = [...path].map((entry, index) => ({ entry, index }))
|
|
62
|
+
.reverse()
|
|
63
|
+
.find(({ entry }) => isMessageEntry(entry) && entry.message.role === "assistant")?.index;
|
|
64
|
+
|
|
65
|
+
if (lastAssistantIndex === undefined) {
|
|
66
|
+
return { leafId: requestedLeafId, adjusted: false, reason: "requested" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const lastAssistant = path[lastAssistantIndex];
|
|
70
|
+
if (!lastAssistant || !isAssistantToolUseEntry(lastAssistant)) {
|
|
71
|
+
return { leafId: requestedLeafId, adjusted: false, reason: "requested" };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let latestUserIndex = -1;
|
|
75
|
+
let latestUserBeforeActiveTurn: UserMessageLike | undefined;
|
|
76
|
+
for (let i = lastAssistantIndex - 1; i >= 0; i -= 1) {
|
|
77
|
+
const candidate = path[i];
|
|
78
|
+
if (!candidate) continue;
|
|
79
|
+
if (isUserMessageEntry(candidate)) {
|
|
80
|
+
latestUserIndex = i;
|
|
81
|
+
latestUserBeforeActiveTurn = candidate.message;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (latestUserIndex > 0 && latestUserBeforeActiveTurn) {
|
|
87
|
+
const boundary = path[latestUserIndex - 1];
|
|
88
|
+
if (boundary) {
|
|
89
|
+
return {
|
|
90
|
+
leafId: boundary.id,
|
|
91
|
+
adjusted: boundary.id !== requestedLeafId,
|
|
92
|
+
reason: "clean-turn-replay",
|
|
93
|
+
replayUserMessage: latestUserBeforeActiveTurn,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (let i = lastAssistantIndex - 1; i >= 0; i -= 1) {
|
|
99
|
+
const candidate = path[i];
|
|
100
|
+
if (!candidate) continue;
|
|
101
|
+
if (isStableAssistantEntry(candidate)) {
|
|
102
|
+
return {
|
|
103
|
+
leafId: candidate.id,
|
|
104
|
+
adjusted: candidate.id !== requestedLeafId,
|
|
105
|
+
reason: "clean-turn-stable",
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (isUserMessageEntry(candidate)) {
|
|
109
|
+
return { leafId: candidate.id, adjusted: candidate.id !== requestedLeafId, reason: "clean-turn-user" };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { leafId: requestedLeafId, adjusted: false, reason: "requested" };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function ensureSessionFileMaterialized(
|
|
117
|
+
sm: Pick<SessionManager, "getHeader" | "getEntries">,
|
|
118
|
+
sessionFile: string,
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
if (fs.existsSync(sessionFile)) return;
|
|
121
|
+
const header = sm.getHeader();
|
|
122
|
+
if (!header) return;
|
|
123
|
+
await fs.promises.mkdir(path.dirname(sessionFile), { recursive: true });
|
|
124
|
+
const lines = [JSON.stringify(header), ...sm.getEntries().map((entry) => JSON.stringify(entry))].join("\n") + "\n";
|
|
125
|
+
await fs.promises.writeFile(sessionFile, lines, "utf8");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function branchSelectionNote(selection: BranchLeafSelection): string {
|
|
129
|
+
if (!selection.adjusted) return "branch";
|
|
130
|
+
if (selection.reason === "clean-turn-replay" || selection.reason === "clean-turn-stable") return "branch(clean-turn)";
|
|
131
|
+
if (selection.reason === "clean-turn-user") return "branch(clean-turn:user-fallback)";
|
|
132
|
+
return "branch";
|
|
133
|
+
}
|