@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 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 "@mariozechner/pi-agent-core";
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 { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { runLeader } from "./leader.js";
3
3
  import { runWorker } from "./worker.js";
4
4
 
@@ -1,4 +1,4 @@
1
- import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { getTeamDir } from "./paths.js";
3
3
  import {
4
4
  acquireTeamAttachClaim,
@@ -1,4 +1,4 @@
1
- import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
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
- try {
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.completedStatus === "failed" ? "task_failed" : "task_completed",
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 (enqueueHook) {
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 = enqueueHook
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 "@mariozechner/pi-coding-agent";
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 "@mariozechner/pi-coding-agent";
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 "@mariozechner/pi-coding-agent";
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 "@mariozechner/pi-coding-agent";
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 "@mariozechner/pi-agent-core";
2
- import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
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 "@mariozechner/pi-coding-agent";
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 "@mariozechner/pi-coding-agent";
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 "@mariozechner/pi-agent-core";
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 "@mariozechner/pi-coding-agent";
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 TeamsActionSchema = StringEnum(
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 = StringEnum(["pending", "in_progress", "completed"] as const, {
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 = StringEnum(["fresh", "branch"] as const, {
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 = StringEnum(["shared", "worktree"] as const, {
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 = StringEnum(["off", "minimal", "low", "medium", "high", "xhigh"] as const, {
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 = StringEnum(["warn", "followup", "reopen", "reopen_followup"] as const, {
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 = StringEnum(["member", "lead", "none"] as const, {
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 "@mariozechner/pi-coding-agent";
5
- import { SessionManager } from "@mariozechner/pi-coding-agent";
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 branched = sm.createBranchedSession(leafId);
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
- return { sessionFile: branched, note: "branch", warnings };
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;
@@ -1,5 +1,5 @@
1
1
  import * as path from "node:path";
2
- import { getAgentDir } from "@mariozechner/pi-coding-agent";
2
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
3
3
 
4
4
  /**
5
5
  * Root directory for all team artifacts (config, sessions, mailboxes, tasks).
@@ -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
+ }