Merge branch 'worktree-fix-permission-request-ui' into dev

This commit is contained in:
iliya 2026-03-27 17:56:08 +02:00
commit 085147ffc0
5 changed files with 265 additions and 3 deletions

View file

@ -32,7 +32,11 @@ import { getMemberColorByName } from '@shared/constants/memberColors';
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
import { resolveLanguageName } from '@shared/utils/agentLanguage';
import { parseCliArgs } from '@shared/utils/cliArgsParser';
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
import {
isInboxNoiseMessage,
parsePermissionRequest,
type ParsedPermissionRequest,
} from '@shared/utils/inboxNoise';
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
@ -4027,12 +4031,35 @@ export class TeamProvisioningService {
);
const deferredIds = new Set(deferredByAge.map((m) => m.messageId));
// Category 4: teammate permission requests — intercept and convert to tool approvals.
// Don't relay these to the lead agent (it can't handle them).
const permissionRequestMsgs = unread.filter(
(m) =>
!permanentlyIgnoredIds.has(m.messageId) &&
!nativeMatchedMessageIds.has(m.messageId) &&
!deferredIds.has(m.messageId) &&
parsePermissionRequest(m.text) !== null
);
const permissionRequestIds = new Set(permissionRequestMsgs.map((m) => m.messageId));
if (permissionRequestMsgs.length > 0) {
for (const msg of permissionRequestMsgs) {
const perm = parsePermissionRequest(msg.text)!;
this.handleTeammatePermissionRequest(run, perm, msg.timestamp);
}
try {
await this.markInboxMessagesRead(teamName, leadName, permissionRequestMsgs);
} catch {
// best-effort
}
}
// Actionable: everything not in any category.
const actionableUnread = unread.filter(
(m) =>
!permanentlyIgnoredIds.has(m.messageId) &&
!nativeMatchedMessageIds.has(m.messageId) &&
!deferredIds.has(m.messageId)
!deferredIds.has(m.messageId) &&
!permissionRequestIds.has(m.messageId)
);
// Layer 3: schedule retry timers.
@ -5536,6 +5563,52 @@ export class TeamProvisioningService {
this.maybeShowToolApprovalOsNotification(run, approval);
}
/**
* Handles a teammate permission_request received via inbox message.
* Converts it to a ToolApprovalRequest and feeds it into the existing approval flow.
*/
private handleTeammatePermissionRequest(
run: ProvisioningRun,
perm: ParsedPermissionRequest,
messageTimestamp: string
): void {
// Skip if already tracked (idempotency — relay can be called multiple times)
if (run.pendingApprovals.has(perm.requestId)) return;
const approval: ToolApprovalRequest = {
requestId: perm.requestId,
runId: run.runId,
teamName: run.teamName,
source: perm.agentId,
toolName: perm.toolName,
toolInput: perm.input,
receivedAt: messageTimestamp || new Date().toISOString(),
teamColor: run.request.color,
teamDisplayName: run.request.displayName,
};
const autoResult = shouldAutoAllow(this.toolApprovalSettings, perm.toolName, perm.input);
if (autoResult.autoAllow) {
logger.info(
`[${run.teamName}] Auto-allowing teammate ${perm.agentId} ${perm.toolName} (${autoResult.reason})`
);
void this.respondToTeammatePermission(run, perm.agentId, perm.requestId, true);
this.emitToolApprovalEvent({
autoResolved: true,
requestId: perm.requestId,
runId: run.runId,
teamName: run.teamName,
reason: 'auto_allow_category',
} as ToolApprovalAutoResolved);
return;
}
run.pendingApprovals.set(perm.requestId, approval);
this.emitToolApprovalEvent(approval);
this.startApprovalTimeout(run, perm.requestId);
this.maybeShowToolApprovalOsNotification(run, approval);
}
/**
* Shows a native OS notification for a pending tool approval when the app
* is not in focus. On macOS, adds Allow/Deny action buttons that respond
@ -5704,6 +5777,31 @@ export class TeamProvisioningService {
const allow = currentAction === 'allow';
logger.info(`[${run.teamName}] Timeout ${allow ? 'allowing' : 'denying'} ${requestId}`);
const approval = run.pendingApprovals.get(requestId);
if (approval && approval.source !== 'lead') {
// Teammate request — respond via inbox + control_response fallback.
// Defer cleanup until the async write completes to avoid silent data loss.
this.respondToTeammatePermission(
run,
approval.source,
requestId,
allow,
allow ? undefined : 'Timed out — auto-denied by settings'
).finally(() => {
run.pendingApprovals.delete(requestId);
this.inFlightResponses.delete(requestId);
this.dismissApprovalNotification(requestId);
this.emitToolApprovalEvent({
autoResolved: true,
requestId,
runId: run.runId,
teamName: run.teamName,
reason: allow ? 'timeout_allow' : 'timeout_deny',
} as ToolApprovalAutoResolved);
});
return;
}
if (allow) {
this.autoAllowControlRequest(run, requestId);
} else {
@ -5768,7 +5866,12 @@ export class TeamProvisioningService {
);
if (result.autoAllow) {
this.clearApprovalTimeout(requestId);
this.autoAllowControlRequest(run, requestId);
if (!this.tryClaimResponse(requestId)) continue;
if (approval.source !== 'lead') {
void this.respondToTeammatePermission(run, approval.source, requestId, true);
} else {
this.autoAllowControlRequest(run, requestId);
}
this.dismissApprovalNotification(requestId);
toRemove.push(requestId);
this.emitToolApprovalEvent({
@ -5794,6 +5897,7 @@ export class TeamProvisioningService {
}
for (const requestId of toRemove) {
run.pendingApprovals.delete(requestId);
this.inFlightResponses.delete(requestId);
}
}
}
@ -5834,6 +5938,20 @@ export class TeamProvisioningService {
return;
}
const approval = run.pendingApprovals.get(requestId)!;
// Teammate permission requests use a different response path (inbox, not stdin)
if (approval.source !== 'lead') {
try {
await this.respondToTeammatePermission(run, approval.source, requestId, allow, message);
} finally {
run.pendingApprovals.delete(requestId);
this.inFlightResponses.delete(requestId);
this.dismissApprovalNotification(requestId);
}
return;
}
if (!run.child?.stdin?.writable) {
throw new Error(`Team "${teamName}" process stdin is not writable`);
}
@ -5889,6 +6007,93 @@ export class TeamProvisioningService {
}
}
/**
* Respond to a teammate's permission_request by writing to the teammate's inbox
* AND attempting a control_response via stdin (belt-and-suspenders).
*/
private async respondToTeammatePermission(
run: ProvisioningRun,
agentId: string,
requestId: string,
allow: boolean,
message?: string
): Promise<void> {
const teamsBase = getTeamsBasePath();
const inboxPath = path.join(teamsBase, run.teamName, 'inboxes', `${agentId}.json`);
// 1. Write permission_response to teammate's inbox (with proper file locking)
const responseMsg = {
from: 'user',
text: JSON.stringify({
type: 'permission_response',
request_id: requestId,
approved: allow,
...(message ? { message } : {}),
}),
timestamp: new Date().toISOString(),
read: false,
};
try {
await withFileLock(inboxPath, async () => {
await withInboxLock(inboxPath, async () => {
let existing: unknown[] = [];
try {
const raw = await tryReadRegularFileUtf8(inboxPath, {
timeoutMs: TEAM_JSON_READ_TIMEOUT_MS,
maxBytes: TEAM_INBOX_MAX_BYTES,
});
if (raw) {
const parsed = JSON.parse(raw) as unknown;
if (Array.isArray(parsed)) existing = parsed;
}
} catch {
// File may not exist yet — start with empty array
}
existing.push(responseMsg);
await atomicWriteAsync(inboxPath, JSON.stringify(existing, null, 2));
});
});
logger.info(
`[${run.teamName}] Wrote permission_response to ${agentId} inbox: ${allow ? 'allow' : 'deny'}`
);
} catch (error) {
logger.error(
`[${run.teamName}] Failed to write permission_response to ${agentId}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
// 2. Also try control_response via stdin (in case lead runtime can forward it)
if (run.child?.stdin?.writable) {
const controlResponse = allow
? {
type: 'control_response',
response: {
subtype: 'success',
request_id: requestId,
response: { behavior: 'allow' },
},
}
: {
type: 'control_response',
response: {
subtype: 'success',
request_id: requestId,
response: { behavior: 'deny', message: message ?? 'User denied' },
},
};
run.child.stdin.write(JSON.stringify(controlResponse) + '\n', (err) => {
if (err) {
logger.warn(
`[${run.teamName}] control_response via stdin for teammate ${agentId} failed (non-critical): ${err.message}`
);
}
});
}
}
/**
* Called when the first stream-json turn completes successfully.
* Verifies provisioning files exist and marks as ready.

View file

@ -202,6 +202,7 @@ export const ToolApprovalSheet: React.FC = () => {
<div className="flex items-center gap-2">
{getToolIcon(current.toolName)}
<span className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
{current.source !== 'lead' ? `${current.source}` : ''}
{current.toolName}
</span>
</div>

View file

@ -222,6 +222,19 @@ function getNoiseLabel(parsed: StructuredMessage): string | null {
: 'Completed a task';
}
if (type === 'permission_request') {
const toolName = getStringField(parsed, 'tool_name');
const description = getStringField(parsed, 'description');
const label = toolName ? `Permission: ${toolName}` : 'Permission request';
return description ? `${label}${description}` : label;
}
if (type === 'permission_response') {
if (parsed.approved === true) return 'Permission granted';
if (parsed.approved === false) return 'Permission denied';
return 'Permission response';
}
return null;
}

View file

@ -73,6 +73,8 @@ const TYPE_LABELS: Record<string, string> = {
shutdown_response: 'Shutdown response',
message: 'Message',
broadcast: 'Broadcast',
permission_request: 'Permission request',
permission_response: 'Permission response',
};
export function parseStructuredAgentMessage(content: string): StructuredAgentMessage | null {

View file

@ -37,6 +37,47 @@ export function isInboxNoiseMessage(text: string): boolean {
return !!type && INBOX_NOISE_SET.has(type);
}
// ---------------------------------------------------------------------------
// Teammate permission request parsing
// ---------------------------------------------------------------------------
/** Parsed teammate permission request from inbox message. */
export interface ParsedPermissionRequest {
requestId: string;
agentId: string;
toolName: string;
toolUseId: string;
description: string;
input: Record<string, unknown>;
}
/**
* Parses a `permission_request` JSON message from a teammate's inbox entry.
* Returns null if the text is not a valid permission_request.
*/
export function parsePermissionRequest(text: string): ParsedPermissionRequest | null {
const parsed = parseInboxJson(text);
if (!parsed || parsed.type !== 'permission_request') return null;
const requestId = typeof parsed.request_id === 'string' ? parsed.request_id : null;
const agentId = typeof parsed.agent_id === 'string' ? parsed.agent_id : null;
const toolName = typeof parsed.tool_name === 'string' ? parsed.tool_name : null;
if (!requestId || !agentId || !toolName) return null;
return {
requestId,
agentId,
toolName,
toolUseId: typeof parsed.tool_use_id === 'string' ? parsed.tool_use_id : '',
description: typeof parsed.description === 'string' ? parsed.description : '',
input:
parsed.input && typeof parsed.input === 'object' && !Array.isArray(parsed.input)
? (parsed.input as Record<string, unknown>)
: {},
};
}
// ---------------------------------------------------------------------------
// Teammate-message XML block detection & stripping
// ---------------------------------------------------------------------------