diff --git a/src/main/http/sessions.ts b/src/main/http/sessions.ts
index 9697d43c..a3bc267c 100644
--- a/src/main/http/sessions.ts
+++ b/src/main/http/sessions.ts
@@ -137,78 +137,76 @@ export function registerSessionRoutes(app: FastifyInstance, services: HttpServic
);
// Session detail
- app.get<{ Params: { projectId: string; sessionId: string } }>(
- '/api/projects/:projectId/sessions/:sessionId',
- async (request) => {
- try {
- const validatedProject = validateProjectId(request.params.projectId);
- const validatedSession = validateSessionId(request.params.sessionId);
- if (!validatedProject.valid || !validatedSession.valid) {
- logger.error(
- `GET session-detail rejected: ${validatedProject.error ?? validatedSession.error ?? 'unknown'}`
- );
- return null;
- }
-
- const safeProjectId = validatedProject.value!;
- const safeSessionId = validatedSession.value!;
- const cacheKey = DataCache.buildKey(safeProjectId, safeSessionId);
-
- // Check cache first
- let sessionDetail = services.dataCache.get(cacheKey);
- if (sessionDetail) {
- return sessionDetail;
- }
-
- const fsType = services.projectScanner.getFileSystemProvider().type;
- // In SSH mode, avoid an extra deep metadata scan before full parse.
- const session = await services.projectScanner.getSessionWithOptions(
- safeProjectId,
- safeSessionId,
- {
- metadataLevel: fsType === 'ssh' ? 'light' : 'deep',
- }
- );
- if (!session) {
- logger.error(`Session not found: ${safeSessionId}`);
- return null;
- }
-
- // Parse session messages
- const parsedSession = await services.sessionParser.parseSession(
- safeProjectId,
- safeSessionId
- );
-
- // Resolve subagents
- const subagents = await services.subagentResolver.resolveSubagents(
- safeProjectId,
- safeSessionId,
- parsedSession.taskCalls,
- parsedSession.messages
- );
- session.hasSubagents = subagents.length > 0;
-
- // Build session detail with chunks
- sessionDetail = services.chunkBuilder.buildSessionDetail(
- session,
- parsedSession.messages,
- subagents
- );
-
- // Cache the result
- services.dataCache.set(cacheKey, sessionDetail);
-
- return sessionDetail;
- } catch (error) {
+ app.get<{
+ Params: { projectId: string; sessionId: string };
+ Querystring: { bypassCache?: string };
+ }>('/api/projects/:projectId/sessions/:sessionId', async (request) => {
+ try {
+ const validatedProject = validateProjectId(request.params.projectId);
+ const validatedSession = validateSessionId(request.params.sessionId);
+ if (!validatedProject.valid || !validatedSession.valid) {
logger.error(
- `Error in GET session-detail for ${request.params.projectId}/${request.params.sessionId}:`,
- error
+ `GET session-detail rejected: ${validatedProject.error ?? validatedSession.error ?? 'unknown'}`
);
return null;
}
+
+ const safeProjectId = validatedProject.value!;
+ const safeSessionId = validatedSession.value!;
+ const cacheKey = DataCache.buildKey(safeProjectId, safeSessionId);
+ const bypassCache = request.query?.bypassCache === 'true';
+
+ // Check cache first
+ let sessionDetail = services.dataCache.get(cacheKey);
+ if (sessionDetail && !bypassCache) {
+ return sessionDetail;
+ }
+
+ const fsType = services.projectScanner.getFileSystemProvider().type;
+ // In SSH mode, avoid an extra deep metadata scan before full parse.
+ const session = await services.projectScanner.getSessionWithOptions(
+ safeProjectId,
+ safeSessionId,
+ {
+ metadataLevel: fsType === 'ssh' ? 'light' : 'deep',
+ }
+ );
+ if (!session) {
+ logger.error(`Session not found: ${safeSessionId}`);
+ return null;
+ }
+
+ // Parse session messages
+ const parsedSession = await services.sessionParser.parseSession(safeProjectId, safeSessionId);
+
+ // Resolve subagents
+ const subagents = await services.subagentResolver.resolveSubagents(
+ safeProjectId,
+ safeSessionId,
+ parsedSession.taskCalls,
+ parsedSession.messages
+ );
+ session.hasSubagents = subagents.length > 0;
+
+ // Build session detail with chunks
+ sessionDetail = services.chunkBuilder.buildSessionDetail(
+ session,
+ parsedSession.messages,
+ subagents
+ );
+
+ // Cache the result
+ services.dataCache.set(cacheKey, sessionDetail);
+
+ return sessionDetail;
+ } catch (error) {
+ logger.error(
+ `Error in GET session-detail for ${request.params.projectId}/${request.params.sessionId}:`,
+ error
+ );
+ return null;
}
- );
+ });
// Conversation groups
app.get<{ Params: { projectId: string; sessionId: string } }>(
diff --git a/src/main/http/subagents.ts b/src/main/http/subagents.ts
index 8b66c3d4..27d4241d 100644
--- a/src/main/http/subagents.ts
+++ b/src/main/http/subagents.ts
@@ -15,63 +15,64 @@ import type { FastifyInstance } from 'fastify';
const logger = createLogger('HTTP:subagents');
export function registerSubagentRoutes(app: FastifyInstance, services: HttpServices): void {
- app.get<{ Params: { projectId: string; sessionId: string; subagentId: string } }>(
- '/api/projects/:projectId/sessions/:sessionId/subagents/:subagentId',
- async (request) => {
- try {
- const validatedProject = validateProjectId(request.params.projectId);
- const validatedSession = validateSessionId(request.params.sessionId);
- const validatedSubagent = validateSubagentId(request.params.subagentId);
- if (!validatedProject.valid || !validatedSession.valid || !validatedSubagent.valid) {
- logger.error(
- `GET subagent-detail rejected: ${
- validatedProject.error ??
- validatedSession.error ??
- validatedSubagent.error ??
- 'Invalid parameters'
- }`
- );
- return null;
- }
-
- const safeProjectId = validatedProject.value!;
- const safeSessionId = validatedSession.value!;
- const safeSubagentId = validatedSubagent.value!;
-
- const cacheKey = `subagent-${safeProjectId}-${safeSessionId}-${safeSubagentId}`;
-
- // Check cache first
- let subagentDetail = services.dataCache.getSubagent(cacheKey);
- if (subagentDetail) {
- return subagentDetail;
- }
-
- const fsProvider = services.projectScanner.getFileSystemProvider();
- const projectsDir = services.projectScanner.getProjectsDir();
-
- const builtDetail = await services.chunkBuilder.buildSubagentDetail(
- safeProjectId,
- safeSessionId,
- safeSubagentId,
- services.sessionParser,
- services.subagentResolver,
- fsProvider,
- projectsDir
+ app.get<{
+ Params: { projectId: string; sessionId: string; subagentId: string };
+ Querystring: { bypassCache?: string };
+ }>('/api/projects/:projectId/sessions/:sessionId/subagents/:subagentId', async (request) => {
+ try {
+ const validatedProject = validateProjectId(request.params.projectId);
+ const validatedSession = validateSessionId(request.params.sessionId);
+ const validatedSubagent = validateSubagentId(request.params.subagentId);
+ if (!validatedProject.valid || !validatedSession.valid || !validatedSubagent.valid) {
+ logger.error(
+ `GET subagent-detail rejected: ${
+ validatedProject.error ??
+ validatedSession.error ??
+ validatedSubagent.error ??
+ 'Invalid parameters'
+ }`
);
-
- if (!builtDetail) {
- logger.error(`Subagent not found: ${safeSubagentId}`);
- return null;
- }
-
- subagentDetail = builtDetail;
- services.dataCache.setSubagent(cacheKey, subagentDetail);
-
- return subagentDetail;
- } catch (error) {
- logger.error(`Error in GET subagent-detail for ${request.params.subagentId}:`, error);
return null;
}
+
+ const safeProjectId = validatedProject.value!;
+ const safeSessionId = validatedSession.value!;
+ const safeSubagentId = validatedSubagent.value!;
+ const bypassCache = request.query?.bypassCache === 'true';
+
+ const cacheKey = `subagent-${safeProjectId}-${safeSessionId}-${safeSubagentId}`;
+
+ // Check cache first
+ let subagentDetail = services.dataCache.getSubagent(cacheKey);
+ if (subagentDetail && !bypassCache) {
+ return subagentDetail;
+ }
+
+ const fsProvider = services.projectScanner.getFileSystemProvider();
+ const projectsDir = services.projectScanner.getProjectsDir();
+
+ const builtDetail = await services.chunkBuilder.buildSubagentDetail(
+ safeProjectId,
+ safeSessionId,
+ safeSubagentId,
+ services.sessionParser,
+ services.subagentResolver,
+ fsProvider,
+ projectsDir
+ );
+
+ if (!builtDetail) {
+ logger.error(`Subagent not found: ${safeSubagentId}`);
+ return null;
+ }
+
+ subagentDetail = builtDetail;
+ services.dataCache.setSubagent(cacheKey, subagentDetail);
+
+ return subagentDetail;
+ } catch (error) {
+ logger.error(`Error in GET subagent-detail for ${request.params.subagentId}:`, error);
+ return null;
}
- );
+ });
}
diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts
index 292ce178..02894f3d 100644
--- a/src/main/services/team/TeamProvisioningService.ts
+++ b/src/main/services/team/TeamProvisioningService.ts
@@ -205,6 +205,16 @@ interface ProvisioningRun {
} | null;
/** Pending tool approval requests awaiting user response (control_request protocol). */
pendingApprovals: Map;
+ /**
+ * Post-compact context reinjection lifecycle.
+ * - pendingPostCompactReminder: compact_boundary was received; waiting for idle to inject.
+ * - postCompactReminderInFlight: the reminder turn has been injected via stdin, waiting for result.
+ * - suppressPostCompactReminderOutput: true while processing a reminder turn — suppress
+ * low-value acknowledgement text so the user doesn't see "OK, I'll remember that."
+ */
+ pendingPostCompactReminder: boolean;
+ postCompactReminderInFlight: boolean;
+ suppressPostCompactReminderOutput: boolean;
}
type LeadActivityState = 'active' | 'idle' | 'offline';
@@ -553,6 +563,76 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string
);
}
+/**
+ * Builds the durable lead context — constraints, communication protocol, teamctl ops,
+ * and agent block policy — that must survive context compaction.
+ *
+ * Used by: buildProvisioningPrompt, buildLaunchPrompt, and post-compact reinjection.
+ */
+function buildPersistentLeadContext(opts: {
+ teamName: string;
+ leadName: string;
+ isSolo: boolean;
+ members: TeamCreateRequest['members'];
+}): string {
+ const { teamName, leadName, isSolo, members } = opts;
+ const languageInstruction = getAgentLanguageInstruction();
+ const agentBlockPolicy = buildAgentBlockUsagePolicy();
+ const teamCtlOps = buildTeamCtlOpsInstructions(teamName, leadName);
+
+ const soloConstraint = isSolo
+ ? `\n- SOLO MODE: This team CURRENTLY has ZERO teammates.` +
+ `\n - FORBIDDEN (until teammates exist): Do NOT spawn teammates via the Task tool with a team_name parameter — there are no teammates to spawn yet.` +
+ `\n - FORBIDDEN (until teammates exist): Do NOT call SendMessage to any teammate name — no teammates exist yet.` +
+ `\n - ALLOWED: You may message "user" (the human operator) via SendMessage.` +
+ `\n - ALLOWED: You may use the Task tool for regular subagents WITHOUT team_name — these are normal Claude Code helpers, not teammates.` +
+ `\n - If teammates are added later (e.g. via UI), you may then spawn them using the Task tool with team_name + name.` +
+ `\n - Work on tasks directly yourself. Use subagents for research and parallel work as needed.` +
+ `\n - PROGRESS REPORTING (MANDATORY): Since you have no teammates, "user" is your only communication channel.` +
+ `\n - SendMessage "user" at minimum: when you start a task (after marking it in_progress), when you complete a task, and when you hit a meaningful milestone/blocker/decision.` +
+ `\n - Avoid long silent stretches. If something is taking longer than expected, send a brief update and the next step.` +
+ `\n - TASK STATUS DISCIPLINE (MANDATORY):` +
+ `\n - Only move a task to in_progress when you are actively starting work on it.` +
+ `\n - Only move a task to completed when it is truly finished.` +
+ `\n - Never bulk-move many tasks at the end — update status incrementally as you work.` +
+ `\n - Default to working ONE task at a time (keep at most one task in_progress in solo mode), unless you explicitly need parallel background work (in that case explain why to "user").` +
+ `\n - Record meaningful progress/decisions as task comments so the task board stays accurate and high-signal.`
+ : '';
+
+ const membersBlock = buildMembersPrompt(members);
+ const membersFooter = membersBlock
+ ? `Members:\n${membersBlock}`
+ : 'Members: (none — solo team lead)';
+
+ return `${languageInstruction}
+
+Constraints:
+- Do NOT call TeamDelete under any circumstances.
+- Do NOT use TodoWrite.
+- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN).
+- Do NOT shut down, terminate, or clean up the team or its members.
+- Do NOT spawn or create a member named "user". "user" is a reserved system name for the human operator — it is NOT a teammate.
+- Keep assistant text minimal.
+- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough.
+- Keep the task board high-signal: avoid creating tasks for trivial micro-items.
+- Use the team task board for assigned/substantial work.
+- DELEGATION-FIRST (behavior rule for ALL future turns): When "user" gives you work, your top priority is to (a) decompose into tasks, (b) create tasks on the team board, (c) assign them to teammates, and (d) SendMessage "user" a short confirmation (task IDs + owners). Do NOT start implementing yourself unless the team is truly in SOLO MODE (no teammates).
+- TaskCreate is optional for private planning only; do NOT use it for team-board tasks.
+- When messaging "user" (the human): NEVER mention teamctl.js, internal scripts, CLI commands, or file paths under ~/.claude/. The user sees messages in the UI — write plain human language. If a task needs a status update, do it yourself via Bash; never ask the user to run a command.${soloConstraint}
+
+${teamCtlOps}
+
+Communication protocol (CRITICAL — you are running headless, no one sees your text output):
+- When you receive a from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient.
+- Your plain text output is invisible to teammates — they are separate processes and can only read their inbox.
+- Example: if you receive ..., respond with SendMessage(type: "message", recipient: "alice", content: "your reply").
+
+Message formatting:
+${agentBlockPolicy}
+
+${membersFooter}`;
+}
+
function buildAgentBlockUsagePolicy(): string {
return `Agent-only formatting policy (applies to ALL messages you write):
- Humans can see teammate inbox messages and coordination text in the UI.
@@ -638,42 +718,20 @@ function buildTaskBoardSnapshot(tasks: TeamTask[]): string {
function buildProvisioningPrompt(request: TeamCreateRequest): string {
const displayName = request.displayName?.trim() || request.teamName;
const description = request.description?.trim() || 'No description';
- const members = buildMembersPrompt(request.members);
const taskProtocol = buildTaskStatusProtocol(request.teamName);
const processRegistration = buildProcessRegistrationProtocol(request.teamName);
- const languageInstruction = getAgentLanguageInstruction();
- const agentBlockPolicy = buildAgentBlockUsagePolicy();
const userPromptBlock = request.prompt?.trim()
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
: '';
const leadName =
request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
- const teamCtlOps = buildTeamCtlOpsInstructions(request.teamName, leadName);
const projectName = path.basename(request.cwd);
const isSolo = request.members.length === 0;
- const soloConstraint = isSolo
- ? `\n- SOLO MODE: This team CURRENTLY has ZERO teammates.` +
- `\n - FORBIDDEN (until teammates exist): Do NOT spawn teammates via the Task tool with a team_name parameter — there are no teammates to spawn yet.` +
- `\n - FORBIDDEN (until teammates exist): Do NOT call SendMessage to any teammate name — no teammates exist yet.` +
- `\n - ALLOWED: You may message "user" (the human operator) via SendMessage.` +
- `\n - ALLOWED: You may use the Task tool for regular subagents WITHOUT team_name — these are normal Claude Code helpers, not teammates.` +
- `\n - If teammates are added later (e.g. via UI), you may then spawn them using the Task tool with team_name + name.` +
- `\n - Work on tasks directly yourself. Use subagents for research and parallel work as needed.` +
- `\n - PROGRESS REPORTING (MANDATORY): Since you have no teammates, "user" is your only communication channel.` +
- `\n - SendMessage "user" at minimum: when you start a task (after marking it in_progress), when you complete a task, and when you hit a meaningful milestone/blocker/decision.` +
- `\n - Avoid long silent stretches. If something is taking longer than expected, send a brief update and the next step.` +
- `\n - TASK STATUS DISCIPLINE (MANDATORY):` +
- `\n - Only move a task to in_progress when you are actively starting work on it.` +
- `\n - Only move a task to completed when it is truly finished.` +
- `\n - Never bulk-move many tasks at the end — update status incrementally as you work.` +
- `\n - Default to working ONE task at a time (keep at most one task in_progress in solo mode), unless you explicitly need parallel background work (in that case explain why to "user").` +
- `\n - Record meaningful progress/decisions as task comments so the task board stays accurate and high-signal.`
- : '';
const step3Block = isSolo
- ? `3) If user instructions describe work to be done — create tasks on the team board and assign each task to yourself ("${leadName}") as owner.\n` +
+ ? `3) If user instructions describe work to be done — create tasks on the team board and assign each task to yourself (“${leadName}”) as owner.\n` +
` - Prefer fewer, broader tasks over many micro-tasks.\n` +
` - CRITICAL: Do NOT start working on the tasks now. Provisioning is ONLY for setting up the team structure.\n` +
` - The tasks will be executed after the team is launched separately.`
@@ -689,7 +747,7 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
- When tasks have natural ordering (e.g. setup → implementation → testing), use --blocked-by.
- If a task is blocked (uses --blocked-by), it MUST be created as pending (use --status pending). Do NOT mark blocked tasks in_progress.
- Review guidance:
- - Prefer NOT creating a separate "review task". Our workflow reviews the work task itself: run review approve/request-changes on the implementation task #X.
+ - Prefer NOT creating a separate “review task”. Our workflow reviews the work task itself: run review approve/request-changes on the implementation task #X.
- If you MUST create a separate review reminder/assignment task, create it as pending and link it to the work task:
- Use --related to connect it to #X (non-blocking link).
- If the review truly cannot start until #X is done, ALSO add --blocked-by #X.
@@ -703,12 +761,12 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
// NOTE: taskProtocol & processRegistration are deliberately inlined into EACH member's spawn prompt
// below, even though the text is identical across members. This duplicates ~4K chars per member
// in the lead's context, but ensures the lead passes the EXACT protocol verbatim via Task tool.
-// Extracting them once and telling the lead to "insert the protocol block" risks hallucination
+// Extracting them once and telling the lead to “insert the protocol block” risks hallucination
// or omission — the lead may rephrase rules, skip items, or forget to include them.
// Cost: ~1K tokens per extra member. At 200K context window this is negligible.
${request.members
.map(
- (m) => ` For "${m.name}":
+ (m) => ` For “${m.name}”:
- prompt:
${buildMemberSpawnPrompt(m, displayName, request.teamName, taskProtocol, processRegistration)
.split('\n')
@@ -717,53 +775,32 @@ ${buildMemberSpawnPrompt(m, displayName, request.teamName, taskProtocol, process
)
.join('\n\n')}`;
- const membersFooter = members ? `Members:\n${members}` : 'Members: (none — solo team lead)';
+ const persistentContext = buildPersistentLeadContext({
+ teamName: request.teamName,
+ leadName,
+ isSolo,
+ members: request.members,
+ });
- return `Team Start [Agent Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"]
+ return `Team Start [Agent Team: “${request.teamName}” | Project: “${projectName}” | Lead: “${leadName}”]
You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
-You are "${leadName}", the team lead.
+You are “${leadName}”, the team lead.
Goal: Provision a Claude Code agent team${request.members.length === 0 ? ' (solo — lead only)' : ' with live teammates'}.
${userPromptBlock}
-${languageInstruction}
-
-Constraints:
-- Do NOT call TeamDelete under any circumstances.
-- Do NOT use TodoWrite.
-- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN).
-- Do NOT shut down, terminate, or clean up the team or its members.
-- Do NOT spawn or create a member named "user". "user" is a reserved system name for the human operator — it is NOT a teammate.
-- Keep assistant text minimal.
-- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough.
-- Keep the task board high-signal: avoid creating tasks for trivial micro-items.
-- Use the team task board for assigned/substantial work.
-- DELEGATION-FIRST (behavior rule for ALL future turns): When "user" gives you work, your top priority is to (a) decompose into tasks, (b) create tasks on the team board, (c) assign them to teammates, and (d) SendMessage "user" a short confirmation (task IDs + owners). Do NOT start implementing yourself unless the team is truly in SOLO MODE (no teammates).
-- TaskCreate is optional for private planning only; do NOT use it for team-board tasks.
-- When messaging "user" (the human): NEVER mention teamctl.js, internal scripts, CLI commands, or file paths under ~/.claude/. The user sees messages in the UI — write plain human language. If a task needs a status update, do it yourself via Bash; never ask the user to run a command.${soloConstraint}
-
-${teamCtlOps}
-
-Communication protocol (CRITICAL — you are running headless, no one sees your text output):
-- When you receive a from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient.
-- Your plain text output is invisible to teammates — they are separate processes and can only read their inbox.
-- Example: if you receive ..., respond with SendMessage(type: "message", recipient: "alice", content: "your reply").
-
-Message formatting:
-${agentBlockPolicy}
+${persistentContext}
Steps (execute in this exact order):
-1) TeamCreate — create team "${request.teamName}":
- - description: "${description}"
+1) TeamCreate — create team “${request.teamName}”:
+ - description: “${description}”
${step2Block}
${step3Block}
4) After all steps, output a short summary.
-
-${membersFooter}
`;
}
@@ -773,39 +810,18 @@ function buildLaunchPrompt(
tasks: TeamTask[],
isResume: boolean
): string {
- const membersBlock = buildMembersPrompt(members);
const userPromptBlock = request.prompt?.trim()
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
: '';
const taskProtocol = buildTaskStatusProtocol(request.teamName);
const processRegistration = buildProcessRegistrationProtocol(request.teamName);
const languageInstruction = getAgentLanguageInstruction();
- const agentBlockPolicy = buildAgentBlockUsagePolicy();
const taskBoardSnapshot = buildTaskBoardSnapshot(tasks);
const leadName = members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
- const teamCtlOps = buildTeamCtlOpsInstructions(request.teamName, leadName);
const projectName = path.basename(request.cwd);
const isSolo = members.length === 0;
- const soloConstraint = isSolo
- ? `\n- SOLO MODE: This team CURRENTLY has ZERO teammates.` +
- `\n - FORBIDDEN (until teammates exist): Do NOT spawn teammates via the Task tool with a team_name parameter — there are no teammates to spawn yet.` +
- `\n - FORBIDDEN (until teammates exist): Do NOT call SendMessage to any teammate name — no teammates exist yet.` +
- `\n - ALLOWED: You may message "user" (the human operator) via SendMessage.` +
- `\n - ALLOWED: You may use the Task tool for regular subagents WITHOUT team_name — these are normal Claude Code helpers, not teammates.` +
- `\n - If teammates are added later (e.g. via UI), you may then spawn them using the Task tool with team_name + name.` +
- `\n - Work on tasks directly yourself. Use subagents for research and parallel work as needed.` +
- `\n - PROGRESS REPORTING (MANDATORY): Since you have no teammates, "user" is your only communication channel.` +
- `\n - SendMessage "user" at minimum: when you start a task (after marking it in_progress), when you complete a task, and when you hit a meaningful milestone/blocker/decision.` +
- `\n - Avoid long silent stretches. If something is taking longer than expected, send a brief update and the next step.` +
- `\n - TASK STATUS DISCIPLINE (MANDATORY):` +
- `\n - Only move a task to in_progress when you are actively starting work on it.` +
- `\n - Only move a task to completed when it is truly finished.` +
- `\n - Never bulk-move many tasks at the end — update status incrementally as you work.` +
- `\n - Default to working ONE task at a time (keep at most one task in_progress in solo mode), unless you explicitly need parallel background work (in that case explain why to "user").` +
- `\n - Record meaningful progress/decisions as task comments so the task board stays accurate and high-signal.`
- : '';
let step2And3Block: string;
if (isSolo) {
@@ -876,9 +892,12 @@ ${memberSpawnInstructions}
3) After spawning all members, check the task board. If any pending tasks are unassigned, assign them to appropriate members using teamctl.`;
}
- const membersFooter = membersBlock
- ? `Members:\n${membersBlock}`
- : 'Members: (none — solo team lead)';
+ const persistentContext = buildPersistentLeadContext({
+ teamName: request.teamName,
+ leadName,
+ isSolo,
+ members,
+ });
const startLabel = isResume ? 'Team Start (resume)' : 'Team Start';
@@ -889,31 +908,8 @@ You are "${leadName}", the team lead.
Goal: Reconnect with existing team "${request.teamName}" and resume pending work.
${userPromptBlock}
-${languageInstruction}
${taskBoardSnapshot}
-Constraints:
-- Do NOT call TeamDelete under any circumstances.
-- Do NOT use TodoWrite.
-- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN).
-- Do NOT shut down, terminate, or clean up the team or its members.
-- Do NOT spawn or create a member named "user". "user" is a reserved system name for the human operator — it is NOT a teammate.
-- Keep assistant text minimal.
-- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough.
-- Keep the task board high-signal: avoid creating tasks for trivial micro-items.
-- Use the team task board for assigned/substantial work.
-- DELEGATION-FIRST (behavior rule for ALL future turns): When "user" gives you work, your top priority is to (a) decompose into tasks, (b) create tasks on the team board, (c) assign them to teammates, and (d) SendMessage "user" a short confirmation (task IDs + owners). Do NOT start implementing yourself unless the team is truly in SOLO MODE (no teammates).
-- TaskCreate is optional for private planning only; do NOT use it for team-board tasks.
-- When messaging "user" (the human): NEVER mention teamctl.js, internal scripts, CLI commands, or file paths under ~/.claude/. The user sees messages in the UI — write plain human language. If a task needs a status update, do it yourself via Bash; never ask the user to run a command.${soloConstraint}
-
-${teamCtlOps}
-
-Communication protocol (CRITICAL — you are running headless, no one sees your text output):
-- When you receive a from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient.
-- Your plain text output is invisible to teammates — they are separate processes and can only read their inbox.
-- Example: if you receive ..., respond with SendMessage(type: "message", recipient: "alice", content: "your reply").
-
-Message formatting:
-${agentBlockPolicy}
+${persistentContext}
Steps (execute in this exact order):
@@ -922,11 +918,19 @@ Steps (execute in this exact order):
${step2And3Block}
4) After all steps, output a short summary of reconnected members and what happens next.
-
-${membersFooter}
`;
}
+/**
+ * Unconditionally clears all post-compact reminder state on a run.
+ * Called from cleanupRun, cancel, and error paths.
+ */
+function clearPostCompactReminderState(run: ProvisioningRun): void {
+ run.pendingPostCompactReminder = false;
+ run.postCompactReminderInFlight = false;
+ run.suppressPostCompactReminderOutput = false;
+}
+
function updateProgress(
run: ProvisioningRun,
state: Exclude,
@@ -1774,6 +1778,9 @@ export class TeamProvisioningService {
authRetryInProgress: false,
spawnContext: null,
pendingApprovals: new Map(),
+ pendingPostCompactReminder: false,
+ postCompactReminderInFlight: false,
+ suppressPostCompactReminderOutput: false,
progress: {
runId,
teamName: request.teamName,
@@ -2077,6 +2084,9 @@ export class TeamProvisioningService {
authRetryInProgress: false,
spawnContext: null,
pendingApprovals: new Map(),
+ pendingPostCompactReminder: false,
+ postCompactReminderInFlight: false,
+ suppressPostCompactReminderOutput: false,
progress: {
runId,
teamName: request.teamName,
@@ -2938,7 +2948,11 @@ export class TeamProvisioningService {
// Push each assistant text block as a separate live message (per-message pattern).
// When the same assistant message includes SendMessage(to:"user"), skip text —
// captureSendMessageToUser() handles it separately.
- if (!run.silentUserDmForward && !hasSendMessageToUser) {
+ if (
+ !run.silentUserDmForward &&
+ !run.suppressPostCompactReminderOutput &&
+ !hasSendMessageToUser
+ ) {
const cleanText = stripAgentBlocks(text).trim();
if (cleanText.length > 0) {
run.leadMsgSeq += 1;
@@ -3004,7 +3018,11 @@ export class TeamProvisioningService {
// (e.g., after session resume when teamContext is lost). We intercept the tool calls
// from stdout and persist them to sentMessages.json under the correct team name,
// ensuring the UI and notifications show the right team.
- if (run.provisioningComplete && !run.silentUserDmForward) {
+ if (
+ run.provisioningComplete &&
+ !run.silentUserDmForward &&
+ !run.suppressPostCompactReminderOutput
+ ) {
this.captureSendMessageToUser(run, content ?? []);
}
@@ -3136,7 +3154,18 @@ export class TeamProvisioningService {
}
if (run.provisioningComplete) {
+ // If this was a post-compact reminder turn completing, clear in-flight and suppress flags.
+ if (run.postCompactReminderInFlight) {
+ clearPostCompactReminderState(run);
+ logger.info(`[${run.teamName}] post-compact reminder turn completed`);
+ }
+
this.setLeadActivity(run, 'idle');
+
+ // Deferred post-compact context reinjection: inject durable rules on first idle after compact.
+ if (run.pendingPostCompactReminder && !run.postCompactReminderInFlight) {
+ void this.injectPostCompactReminder(run);
+ }
}
if (run.leadRelayCapture) {
const capture = run.leadRelayCapture;
@@ -3182,7 +3211,14 @@ export class TeamProvisioningService {
killProcessTree(run.child);
this.cleanupRun(run);
} else if (run.provisioningComplete) {
- // Post-provisioning error: process alive, waiting for input
+ // Post-provisioning error: process alive, waiting for input.
+ // Drop post-compact reminder on error (strict drop-after-attempt policy).
+ if (run.postCompactReminderInFlight) {
+ clearPostCompactReminderState(run);
+ logger.warn(
+ `[${run.teamName}] post-compact reminder turn errored — dropping (strict policy)`
+ );
+ }
this.setLeadActivity(run, 'idle');
}
}
@@ -3220,10 +3256,152 @@ export class TeamProvisioningService {
logger.info(
`[${run.teamName}] compact_boundary — context will refresh on next turn${tokenInfo}`
);
+
+ // Schedule post-compact context reinjection on next idle.
+ // Guard: only set if provisioning is complete and no reminder is already pending/in-flight.
+ if (
+ run.provisioningComplete &&
+ !run.pendingPostCompactReminder &&
+ !run.postCompactReminderInFlight
+ ) {
+ run.pendingPostCompactReminder = true;
+ logger.info(`[${run.teamName}] post-compact reminder scheduled for next idle`);
+ }
}
}
}
+ /**
+ * Injects a post-compact context reminder into the lead process via stdin.
+ * Reinjects durable lead rules (constraints, communication protocol, teamctl ops)
+ * plus a fresh task board snapshot so the lead recovers full operational context
+ * after context compaction.
+ *
+ * Policy: strict drop-after-attempt — one compact cycle gives at most one reminder turn.
+ * If the injection fails (stdin not writable, process killed), we do not retry.
+ */
+ private async injectPostCompactReminder(run: ProvisioningRun): Promise {
+ // Consume the pending flag immediately — strict one-shot policy.
+ run.pendingPostCompactReminder = false;
+
+ // Guard: process must be alive and writable.
+ if (!run.child?.stdin?.writable || run.processKilled || run.cancelRequested) {
+ logger.warn(
+ `[${run.teamName}] post-compact reminder skipped — process not writable or killed`
+ );
+ return;
+ }
+
+ // Guard: don't inject if another turn is actively processing (race with user send / inbox relay).
+ if (run.leadActivityState !== 'idle') {
+ logger.info(
+ `[${run.teamName}] post-compact reminder deferred — lead is ${run.leadActivityState}, not idle`
+ );
+ // Re-arm so it triggers on next idle.
+ run.pendingPostCompactReminder = true;
+ return;
+ }
+
+ // Guard: don't inject while a relay capture is in-flight.
+ if (run.leadRelayCapture) {
+ logger.info(`[${run.teamName}] post-compact reminder deferred — relay capture in-flight`);
+ run.pendingPostCompactReminder = true;
+ return;
+ }
+
+ // Guard: don't inject while a silent DM forward is in progress.
+ if (run.silentUserDmForward) {
+ logger.info(
+ `[${run.teamName}] post-compact reminder deferred — silent DM forward in progress`
+ );
+ run.pendingPostCompactReminder = true;
+ return;
+ }
+
+ const leadName =
+ run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
+ const isSolo = run.request.members.length === 0;
+
+ // Build persistent lead context.
+ const persistentContext = buildPersistentLeadContext({
+ teamName: run.teamName,
+ leadName,
+ isSolo,
+ members: run.request.members,
+ });
+
+ // Best-effort: fetch fresh task board snapshot.
+ let taskBoardBlock = '';
+ try {
+ const taskReader = new TeamTaskReader();
+ const tasks = await taskReader.getTasks(run.teamName);
+ taskBoardBlock = buildTaskBoardSnapshot(tasks);
+ } catch {
+ // If tasks can't be read, inject without the snapshot.
+ logger.warn(`[${run.teamName}] post-compact reminder: task board snapshot unavailable`);
+ }
+
+ // Re-check guards after async work.
+ if (!run.child?.stdin?.writable || run.processKilled || run.cancelRequested) {
+ logger.warn(
+ `[${run.teamName}] post-compact reminder aborted — process state changed during preparation`
+ );
+ return;
+ }
+ if (run.leadActivityState !== 'idle') {
+ logger.info(
+ `[${run.teamName}] post-compact reminder aborted — lead activity changed to ${run.leadActivityState}`
+ );
+ return;
+ }
+
+ const message = [
+ `Context reminder (post-compaction) — your context was compacted. Here are your standing rules and current state:`,
+ ``,
+ `You are "${leadName}", the team lead of team "${run.teamName}".`,
+ `You are running in a non-interactive CLI session. Do not ask questions.`,
+ ``,
+ persistentContext,
+ taskBoardBlock.trim() ? `\n${taskBoardBlock}` : '',
+ ``,
+ `Acknowledge briefly (1 sentence max) and continue with any pending work.`,
+ ]
+ .filter(Boolean)
+ .join('\n');
+
+ const payload = JSON.stringify({
+ type: 'user',
+ message: {
+ role: 'user',
+ content: [{ type: 'text', text: message }],
+ },
+ });
+
+ run.postCompactReminderInFlight = true;
+ run.suppressPostCompactReminderOutput = true;
+ this.setLeadActivity(run, 'active');
+
+ try {
+ const stdin = run.child.stdin;
+ await new Promise((resolve, reject) => {
+ stdin.write(payload + '\n', (err) => {
+ if (err) reject(err);
+ else resolve();
+ });
+ });
+ logger.info(`[${run.teamName}] post-compact reminder injected`);
+ } catch (error) {
+ // Strict drop-after-attempt — do not re-arm.
+ clearPostCompactReminderState(run);
+ this.setLeadActivity(run, 'idle');
+ logger.warn(
+ `[${run.teamName}] post-compact reminder injection failed: ${
+ error instanceof Error ? error.message : String(error)
+ }`
+ );
+ }
+ }
+
/**
* Handles a control_request message from CLI stream-json output.
* `can_use_tool` → emits to renderer for manual approval.
@@ -3552,6 +3730,7 @@ export class TeamProvisioningService {
clearTimeout(run.silentUserDmForwardClearHandle);
run.silentUserDmForwardClearHandle = null;
}
+ clearPostCompactReminderState(run);
this.stopFilesystemMonitor(run);
// Remove stream listeners to prevent data handlers firing on a cleaned-up run
if (run.child) {
diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts
index 58bb724e..30cb0f65 100644
--- a/src/renderer/api/httpClient.ts
+++ b/src/renderer/api/httpClient.ts
@@ -249,11 +249,15 @@ export class HttpAPIClient implements ElectronAPI {
getSessionDetail = (
projectId: string,
sessionId: string,
- _options?: { bypassCache?: boolean }
- ): Promise =>
- this.get(
- `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}`
+ options?: { bypassCache?: boolean }
+ ): Promise => {
+ const params = new URLSearchParams();
+ if (options?.bypassCache) params.set('bypassCache', 'true');
+ const qs = params.toString();
+ return this.get(
+ `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}${qs ? `?${qs}` : ''}`
);
+ };
getSessionMetrics = (projectId: string, sessionId: string): Promise =>
this.get(
@@ -269,11 +273,15 @@ export class HttpAPIClient implements ElectronAPI {
projectId: string,
sessionId: string,
subagentId: string,
- _options?: { bypassCache?: boolean }
- ): Promise =>
- this.get(
- `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}`
+ options?: { bypassCache?: boolean }
+ ): Promise => {
+ const params = new URLSearchParams();
+ if (options?.bypassCache) params.set('bypassCache', 'true');
+ const qs = params.toString();
+ return this.get(
+ `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}${qs ? `?${qs}` : ''}`
);
+ };
getSessionGroups = (projectId: string, sessionId: string): Promise =>
this.get(
diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx
index 2d935969..b8631861 100644
--- a/src/renderer/components/team/activity/ActivityItem.tsx
+++ b/src/renderer/components/team/activity/ActivityItem.tsx
@@ -157,8 +157,8 @@ const AUTH_ERROR_PATTERNS = [
// ---------------------------------------------------------------------------
/** Convert `#` in plain text to markdown links with task:// protocol. */
-function linkifyTaskIdsInMarkdown(text: string): string {
- return text.replace(/#(\d+)/g, '[#$1](task://$1)');
+export function linkifyTaskIdsInMarkdown(text: string): string {
+ return text.replace(/#(\d+)\b/g, '[#$1](task://$1)');
}
/**
@@ -166,7 +166,10 @@ function linkifyTaskIdsInMarkdown(text: string): string {
* Encodes color in the URL so MarkdownViewer can render colored badges without extra context.
* Greedy match: longer names are tried first to avoid partial matches.
*/
-function linkifyMentionsInMarkdown(text: string, memberColorMap: Map): string {
+export function linkifyMentionsInMarkdown(
+ text: string,
+ memberColorMap: Map
+): string {
if (memberColorMap.size === 0) return text;
// Sort by name length descending for greedy matching
const names = [...memberColorMap.keys()].sort((a, b) => b.length - a.length);
@@ -182,7 +185,7 @@ function linkifyMentionsInMarkdown(text: string, memberColorMap: Map` in plain text as clickable inline elements with TaskTooltip. */
function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] {
- return text.split(/(#\d+)/g).map((part, i) => {
+ return text.split(/(#\d+\b)/g).map((part, i) => {
const match = /^#(\d+)$/.exec(part);
if (!match) return {part};
const taskId = match[1];
diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx
index bc8eeff3..81a9492e 100644
--- a/src/renderer/components/team/activity/ActivityTimeline.tsx
+++ b/src/renderer/components/team/activity/ActivityTimeline.tsx
@@ -352,6 +352,8 @@ export const ActivityTimeline = ({
onVisible={onMessageVisible}
zebraShade={zebraShadeSet.has(0)}
collapseState={collapseState}
+ onTaskIdClick={onTaskIdClick}
+ memberColorMap={colorMap}
/>
);
})()}
@@ -397,6 +399,8 @@ export const ActivityTimeline = ({
onVisible={onMessageVisible}
zebraShade={zebraShadeSet.has(realIndex)}
collapseState={collapseState}
+ onTaskIdClick={onTaskIdClick}
+ memberColorMap={colorMap}
/>
);
diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
index e8c964dd..02fdc485 100644
--- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
+++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
@@ -16,6 +16,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store';
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
+import { linkifyMentionsInMarkdown, linkifyTaskIdsInMarkdown } from './ActivityItem';
import { isManagedCollapseState } from './collapseState';
import type { ActivityCollapseState } from './collapseState';
@@ -97,6 +98,10 @@ interface LeadThoughtsGroupRowProps {
zebraShade?: boolean;
/** Explicit collapse state for timeline-controlled collapsed mode. */
collapseState?: ActivityCollapseState;
+ /** Called when a task ID link (e.g. #10) is clicked in thought text. */
+ onTaskIdClick?: (taskId: string) => void;
+ /** Map of member name → color name for @mention badge rendering. */
+ memberColorMap?: Map;
}
function formatTime(timestamp: string): string {
@@ -179,12 +184,16 @@ interface LeadThoughtItemProps {
thought: InboxMessage;
showDivider: boolean;
shouldAnimate: boolean;
+ onTaskIdClick?: (taskId: string) => void;
+ memberColorMap?: Map;
}
const LeadThoughtItem = ({
thought,
showDivider,
shouldAnimate,
+ onTaskIdClick,
+ memberColorMap,
}: LeadThoughtItemProps): JSX.Element => {
const wrapperRef = useRef(null);
const contentRef = useRef(null);
@@ -192,6 +201,15 @@ const LeadThoughtItem = ({
const animationFrameRef = useRef(null);
const cleanupTimerRef = useRef(null);
+ const displayContent = useMemo(() => {
+ let text = thought.text.replace(/\n/g, ' \n');
+ text = linkifyTaskIdsInMarkdown(text);
+ if (memberColorMap && memberColorMap.size > 0) {
+ text = linkifyMentionsInMarkdown(text, memberColorMap);
+ }
+ return text;
+ }, [thought.text, memberColorMap]);
+
const clearPendingAnimation = useCallback(() => {
if (animationFrameRef.current !== null) {
cancelAnimationFrame(animationFrameRef.current);
@@ -313,11 +331,25 @@ const LeadThoughtItem = ({
)}