Remove legacy swarm permission disk sync

This commit is contained in:
2026-04-04 08:49:15 +08:00
parent f65baebb3c
commit f06a2c2740
5 changed files with 60 additions and 663 deletions

View File

@@ -1,31 +1,16 @@
/** /**
* Swarm Permission Poller Hook * Swarm permission callback registry helpers.
* *
* This hook polls for permission responses from the team leader when running * Permission requests/responses now flow entirely through teammate mailboxes.
* as a worker agent in a swarm. When a response is received, it calls the * Workers register callbacks here, and the inbox poller dispatches mailbox
* appropriate callback (onAllow/onReject) to continue execution. * responses back into those callbacks.
*
* This hook should be used in conjunction with the worker-side integration
* in useCanUseTool.ts, which creates pending requests that this hook monitors.
*/ */
import { useCallback, useEffect, useRef } from 'react'
import { useInterval } from 'usehooks-ts'
import { logForDebugging } from '../utils/debug.js' import { logForDebugging } from '../utils/debug.js'
import { errorMessage } from '../utils/errors.js'
import { import {
type PermissionUpdate, type PermissionUpdate,
permissionUpdateSchema, permissionUpdateSchema,
} from '../utils/permissions/PermissionUpdateSchema.js' } from '../utils/permissions/PermissionUpdateSchema.js'
import {
isSwarmWorker,
type PermissionResponse,
pollForResponse,
removeWorkerResponse,
} from '../utils/swarm/permissionSync.js'
import { getAgentName, getTeamName } from '../utils/teammate.js'
const POLL_INTERVAL_MS = 500
/** /**
* Validate permissionUpdates from external sources (mailbox IPC, disk polling). * Validate permissionUpdates from external sources (mailbox IPC, disk polling).
@@ -226,105 +211,9 @@ export function processSandboxPermissionResponse(params: {
} }
/** /**
* Process a permission response by invoking the registered callback * Legacy no-op hook kept for compatibility with older imports.
*/ * Mailbox responses are handled by useInboxPoller instead of disk polling.
function processResponse(response: PermissionResponse): boolean {
const callback = pendingCallbacks.get(response.requestId)
if (!callback) {
logForDebugging(
`[SwarmPermissionPoller] No callback registered for request ${response.requestId}`,
)
return false
}
logForDebugging(
`[SwarmPermissionPoller] Processing response for request ${response.requestId}: ${response.decision}`,
)
// Remove from registry before invoking callback
pendingCallbacks.delete(response.requestId)
if (response.decision === 'approved') {
const permissionUpdates = parsePermissionUpdates(response.permissionUpdates)
const updatedInput = response.updatedInput
callback.onAllow(updatedInput, permissionUpdates)
} else {
callback.onReject(response.feedback)
}
return true
}
/**
* Hook that polls for permission responses when running as a swarm worker.
*
* This hook:
* 1. Only activates when isSwarmWorker() returns true
* 2. Polls every 500ms for responses
* 3. When a response is found, invokes the registered callback
* 4. Cleans up the response file after processing
*/ */
export function useSwarmPermissionPoller(): void { export function useSwarmPermissionPoller(): void {
const isProcessingRef = useRef(false) // Intentionally empty.
const poll = useCallback(async () => {
// Don't poll if not a swarm worker
if (!isSwarmWorker()) {
return
}
// Prevent concurrent polling
if (isProcessingRef.current) {
return
}
// Don't poll if no callbacks are registered
if (pendingCallbacks.size === 0) {
return
}
isProcessingRef.current = true
try {
const agentName = getAgentName()
const teamName = getTeamName()
if (!agentName || !teamName) {
return
}
// Check each pending request for a response
for (const [requestId, _callback] of pendingCallbacks) {
const response = await pollForResponse(requestId, agentName, teamName)
if (response) {
// Process the response
const processed = processResponse(response)
if (processed) {
// Clean up the response from the worker's inbox
await removeWorkerResponse(requestId, agentName, teamName)
}
}
}
} catch (error) {
logForDebugging(
`[SwarmPermissionPoller] Error during poll: ${errorMessage(error)}`,
)
} finally {
isProcessingRef.current = false
}
}, [])
// Only poll if we're a swarm worker
const shouldPoll = isSwarmWorker()
useInterval(() => void poll(), shouldPoll ? POLL_INTERVAL_MS : null)
// Initial poll on mount
useEffect(() => {
if (isSwarmWorker()) {
void poll()
}
}, [poll])
} }

View File

@@ -116,7 +116,6 @@ function appendToLog(path: string, message: object): void {
const messageWithTimestamp = { const messageWithTimestamp = {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
...message, ...message,
cwd: getFsImplementation().cwd(),
userType: process.env.USER_TYPE, userType: process.env.USER_TYPE,
sessionId: getSessionId(), sessionId: getSessionId(),
version: MACRO.VERSION, version: MACRO.VERSION,
@@ -125,25 +124,12 @@ function appendToLog(path: string, message: object): void {
getLogWriter(path).write(messageWithTimestamp) getLogWriter(path).write(messageWithTimestamp)
} }
function extractServerMessage(data: unknown): string | undefined { function summarizeUrlForLogs(url: string): string | undefined {
if (typeof data === 'string') { try {
return data return new URL(url).host || undefined
} catch {
return undefined
} }
if (data && typeof data === 'object') {
const obj = data as Record<string, unknown>
if (typeof obj.message === 'string') {
return obj.message
}
if (
typeof obj.error === 'object' &&
obj.error &&
'message' in obj.error &&
typeof (obj.error as Record<string, unknown>).message === 'string'
) {
return (obj.error as Record<string, unknown>).message as string
}
}
return undefined
} }
/** /**
@@ -155,15 +141,15 @@ function logErrorImpl(error: Error): void {
// Enrich axios errors with request URL, status, and server message for debugging // Enrich axios errors with request URL, status, and server message for debugging
let context = '' let context = ''
if (axios.isAxiosError(error) && error.config?.url) { if (axios.isAxiosError(error) && error.config?.url) {
const parts = [`url=${error.config.url}`] const parts: string[] = []
const host = summarizeUrlForLogs(error.config.url)
if (host) {
parts.push(`host=${host}`)
}
if (error.response?.status !== undefined) { if (error.response?.status !== undefined) {
parts.push(`status=${error.response.status}`) parts.push(`status=${error.response.status}`)
} }
const serverMessage = extractServerMessage(error.response?.data) context = parts.length > 0 ? `[${parts.join(',')}] ` : ''
if (serverMessage) {
parts.push(`body=${serverMessage}`)
}
context = `[${parts.join(',')}] `
} }
logForDebugging(`${error.name}: ${context}${errorStr}`, { level: 'error' }) logForDebugging(`${error.name}: ${context}${errorStr}`, { level: 'error' })
@@ -188,7 +174,6 @@ function logMCPErrorImpl(serverName: string, error: unknown): void {
error: errorStr, error: errorStr,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
sessionId: getSessionId(), sessionId: getSessionId(),
cwd: getFsImplementation().cwd(),
} }
getLogWriter(logFile).write(errorInfo) getLogWriter(logFile).write(errorInfo)
@@ -206,7 +191,6 @@ function logMCPDebugImpl(serverName: string, message: string): void {
debug: message, debug: message,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
sessionId: getSessionId(), sessionId: getSessionId(),
cwd: getFsImplementation().cwd(),
} }
getLogWriter(logFile).write(debugInfo) getLogWriter(logFile).write(debugInfo)

View File

@@ -301,7 +301,7 @@ export const setupGracefulShutdown = memoize(() => {
process.on('uncaughtException', error => { process.on('uncaughtException', error => {
logForDiagnosticsNoPII('error', 'uncaught_exception', { logForDiagnosticsNoPII('error', 'uncaught_exception', {
error_name: error.name, error_name: error.name,
error_message: error.message.slice(0, 2000), has_message: error.message.length > 0,
}) })
logEvent('tengu_uncaught_exception', { logEvent('tengu_uncaught_exception', {
error_name: error_name:
@@ -321,10 +321,10 @@ export const setupGracefulShutdown = memoize(() => {
reason instanceof Error reason instanceof Error
? { ? {
error_name: reason.name, error_name: reason.name,
error_message: reason.message.slice(0, 2000), has_message: reason.message.length > 0,
error_stack: reason.stack?.slice(0, 4000), has_stack: Boolean(reason.stack),
} }
: { error_message: String(reason).slice(0, 2000) } : { reason_type: typeof reason }
logForDiagnosticsNoPII('error', 'unhandled_rejection', errorInfo) logForDiagnosticsNoPII('error', 'unhandled_rejection', errorInfo)
logEvent('tengu_unhandled_rejection', { logEvent('tengu_unhandled_rejection', {
error_name: error_name:

View File

@@ -18,16 +18,10 @@
* 6. Worker polls mailbox for responses and continues execution * 6. Worker polls mailbox for responses and continues execution
*/ */
import { mkdir, readdir, readFile, unlink, writeFile } from 'fs/promises'
import { join } from 'path'
import { z } from 'zod/v4'
import { logForDebugging } from '../debug.js' import { logForDebugging } from '../debug.js'
import { getErrnoCode } from '../errors.js'
import { lazySchema } from '../lazySchema.js'
import * as lockfile from '../lockfile.js'
import { logError } from '../log.js' import { logError } from '../log.js'
import type { PermissionUpdate } from '../permissions/PermissionUpdateSchema.js' import type { PermissionUpdate } from '../permissions/PermissionUpdateSchema.js'
import { jsonParse, jsonStringify } from '../slowOperations.js' import { jsonStringify } from '../slowOperations.js'
import { import {
getAgentId, getAgentId,
getAgentName, getAgentName,
@@ -41,53 +35,44 @@ import {
createSandboxPermissionResponseMessage, createSandboxPermissionResponseMessage,
writeToMailbox, writeToMailbox,
} from '../teammateMailbox.js' } from '../teammateMailbox.js'
import { getTeamDir, readTeamFileAsync } from './teamHelpers.js' import { readTeamFileAsync } from './teamHelpers.js'
/** export type SwarmPermissionRequest = {
* Full request schema for a permission request from a worker to the leader /** Unique identifier for this request */
*/ id: string
export const SwarmPermissionRequestSchema = lazySchema(() => /** Worker's CLAUDE_CODE_AGENT_ID */
z.object({ workerId: string
/** Unique identifier for this request */ /** Worker's CLAUDE_CODE_AGENT_NAME */
id: z.string(), workerName: string
/** Worker's CLAUDE_CODE_AGENT_ID */ /** Worker's CLAUDE_CODE_AGENT_COLOR */
workerId: z.string(), workerColor?: string
/** Worker's CLAUDE_CODE_AGENT_NAME */ /** Team name for routing */
workerName: z.string(), teamName: string
/** Worker's CLAUDE_CODE_AGENT_COLOR */ /** Tool name requiring permission (e.g., "Bash", "Edit") */
workerColor: z.string().optional(), toolName: string
/** Team name for routing */ /** Original toolUseID from worker's context */
teamName: z.string(), toolUseId: string
/** Tool name requiring permission (e.g., "Bash", "Edit") */ /** Human-readable description of the tool use */
toolName: z.string(), description: string
/** Original toolUseID from worker's context */ /** Serialized tool input */
toolUseId: z.string(), input: Record<string, unknown>
/** Human-readable description of the tool use */ /** Suggested permission rules from the permission result */
description: z.string(), permissionSuggestions: unknown[]
/** Serialized tool input */ /** Status of the request */
input: z.record(z.string(), z.unknown()), status: 'pending' | 'approved' | 'rejected'
/** Suggested permission rules from the permission result */ /** Who resolved the request */
permissionSuggestions: z.array(z.unknown()), resolvedBy?: 'worker' | 'leader'
/** Status of the request */ /** Timestamp when resolved */
status: z.enum(['pending', 'approved', 'rejected']), resolvedAt?: number
/** Who resolved the request */ /** Rejection feedback message */
resolvedBy: z.enum(['worker', 'leader']).optional(), feedback?: string
/** Timestamp when resolved */ /** Modified input if changed by resolver */
resolvedAt: z.number().optional(), updatedInput?: Record<string, unknown>
/** Rejection feedback message */ /** "Always allow" rules applied during resolution */
feedback: z.string().optional(), permissionUpdates?: unknown[]
/** Modified input if changed by resolver */ /** Timestamp when request was created */
updatedInput: z.record(z.string(), z.unknown()).optional(), createdAt: number
/** "Always allow" rules applied during resolution */ }
permissionUpdates: z.array(z.unknown()).optional(),
/** Timestamp when request was created */
createdAt: z.number(),
}),
)
export type SwarmPermissionRequest = z.infer<
ReturnType<typeof SwarmPermissionRequestSchema>
>
/** /**
* Resolution data returned when leader/worker resolves a request * Resolution data returned when leader/worker resolves a request
@@ -105,55 +90,6 @@ export type PermissionResolution = {
permissionUpdates?: PermissionUpdate[] permissionUpdates?: PermissionUpdate[]
} }
/**
* Get the base directory for a team's permission requests
* Path: ~/.claude/teams/{teamName}/permissions/
*/
export function getPermissionDir(teamName: string): string {
return join(getTeamDir(teamName), 'permissions')
}
/**
* Get the pending directory for a team
*/
function getPendingDir(teamName: string): string {
return join(getPermissionDir(teamName), 'pending')
}
/**
* Get the resolved directory for a team
*/
function getResolvedDir(teamName: string): string {
return join(getPermissionDir(teamName), 'resolved')
}
/**
* Ensure the permissions directory structure exists (async)
*/
async function ensurePermissionDirsAsync(teamName: string): Promise<void> {
const permDir = getPermissionDir(teamName)
const pendingDir = getPendingDir(teamName)
const resolvedDir = getResolvedDir(teamName)
for (const dir of [permDir, pendingDir, resolvedDir]) {
await mkdir(dir, { recursive: true })
}
}
/**
* Get the path to a pending request file
*/
function getPendingRequestPath(teamName: string, requestId: string): string {
return join(getPendingDir(teamName), `${requestId}.json`)
}
/**
* Get the path to a resolved request file
*/
function getResolvedRequestPath(teamName: string, requestId: string): string {
return join(getResolvedDir(teamName), `${requestId}.json`)
}
/** /**
* Generate a unique request ID * Generate a unique request ID
*/ */
@@ -206,375 +142,6 @@ export function createPermissionRequest(params: {
} }
} }
/**
* Write a permission request to the pending directory with file locking
* Called by worker agents when they need permission approval from the leader
*
* @returns The written request
*/
export async function writePermissionRequest(
request: SwarmPermissionRequest,
): Promise<SwarmPermissionRequest> {
await ensurePermissionDirsAsync(request.teamName)
const pendingPath = getPendingRequestPath(request.teamName, request.id)
const lockDir = getPendingDir(request.teamName)
// Create a directory-level lock file for atomic writes
const lockFilePath = join(lockDir, '.lock')
await writeFile(lockFilePath, '', 'utf-8')
let release: (() => Promise<void>) | undefined
try {
release = await lockfile.lock(lockFilePath)
// Write the request file
await writeFile(pendingPath, jsonStringify(request, null, 2), 'utf-8')
logForDebugging(
`[PermissionSync] Wrote pending request ${request.id} from ${request.workerName} for ${request.toolName}`,
)
return request
} catch (error) {
logForDebugging(
`[PermissionSync] Failed to write permission request: ${error}`,
)
logError(error)
throw error
} finally {
if (release) {
await release()
}
}
}
/**
* Read all pending permission requests for a team
* Called by the team leader to see what requests need attention
*/
export async function readPendingPermissions(
teamName?: string,
): Promise<SwarmPermissionRequest[]> {
const team = teamName || getTeamName()
if (!team) {
logForDebugging('[PermissionSync] No team name available')
return []
}
const pendingDir = getPendingDir(team)
let files: string[]
try {
files = await readdir(pendingDir)
} catch (e: unknown) {
const code = getErrnoCode(e)
if (code === 'ENOENT') {
return []
}
logForDebugging(`[PermissionSync] Failed to read pending requests: ${e}`)
logError(e)
return []
}
const jsonFiles = files.filter(f => f.endsWith('.json') && f !== '.lock')
const results = await Promise.all(
jsonFiles.map(async file => {
const filePath = join(pendingDir, file)
try {
const content = await readFile(filePath, 'utf-8')
const parsed = SwarmPermissionRequestSchema().safeParse(
jsonParse(content),
)
if (parsed.success) {
return parsed.data
}
logForDebugging(
`[PermissionSync] Invalid request file ${file}: ${parsed.error.message}`,
)
return null
} catch (err) {
logForDebugging(
`[PermissionSync] Failed to read request file ${file}: ${err}`,
)
return null
}
}),
)
const requests = results.filter(r => r !== null)
// Sort by creation time (oldest first)
requests.sort((a, b) => a.createdAt - b.createdAt)
return requests
}
/**
* Read a resolved permission request by ID
* Called by workers to check if their request has been resolved
*
* @returns The resolved request, or null if not yet resolved
*/
export async function readResolvedPermission(
requestId: string,
teamName?: string,
): Promise<SwarmPermissionRequest | null> {
const team = teamName || getTeamName()
if (!team) {
return null
}
const resolvedPath = getResolvedRequestPath(team, requestId)
try {
const content = await readFile(resolvedPath, 'utf-8')
const parsed = SwarmPermissionRequestSchema().safeParse(jsonParse(content))
if (parsed.success) {
return parsed.data
}
logForDebugging(
`[PermissionSync] Invalid resolved request ${requestId}: ${parsed.error.message}`,
)
return null
} catch (e: unknown) {
const code = getErrnoCode(e)
if (code === 'ENOENT') {
return null
}
logForDebugging(
`[PermissionSync] Failed to read resolved request ${requestId}: ${e}`,
)
logError(e)
return null
}
}
/**
* Resolve a permission request
* Called by the team leader (or worker in self-resolution cases)
*
* Writes the resolution to resolved/, removes from pending/
*/
export async function resolvePermission(
requestId: string,
resolution: PermissionResolution,
teamName?: string,
): Promise<boolean> {
const team = teamName || getTeamName()
if (!team) {
logForDebugging('[PermissionSync] No team name available')
return false
}
await ensurePermissionDirsAsync(team)
const pendingPath = getPendingRequestPath(team, requestId)
const resolvedPath = getResolvedRequestPath(team, requestId)
const lockFilePath = join(getPendingDir(team), '.lock')
await writeFile(lockFilePath, '', 'utf-8')
let release: (() => Promise<void>) | undefined
try {
release = await lockfile.lock(lockFilePath)
// Read the pending request
let content: string
try {
content = await readFile(pendingPath, 'utf-8')
} catch (e: unknown) {
const code = getErrnoCode(e)
if (code === 'ENOENT') {
logForDebugging(
`[PermissionSync] Pending request not found: ${requestId}`,
)
return false
}
throw e
}
const parsed = SwarmPermissionRequestSchema().safeParse(jsonParse(content))
if (!parsed.success) {
logForDebugging(
`[PermissionSync] Invalid pending request ${requestId}: ${parsed.error.message}`,
)
return false
}
const request = parsed.data
// Update the request with resolution data
const resolvedRequest: SwarmPermissionRequest = {
...request,
status: resolution.decision === 'approved' ? 'approved' : 'rejected',
resolvedBy: resolution.resolvedBy,
resolvedAt: Date.now(),
feedback: resolution.feedback,
updatedInput: resolution.updatedInput,
permissionUpdates: resolution.permissionUpdates,
}
// Write to resolved directory
await writeFile(
resolvedPath,
jsonStringify(resolvedRequest, null, 2),
'utf-8',
)
// Remove from pending directory
await unlink(pendingPath)
logForDebugging(
`[PermissionSync] Resolved request ${requestId} with ${resolution.decision}`,
)
return true
} catch (error) {
logForDebugging(`[PermissionSync] Failed to resolve request: ${error}`)
logError(error)
return false
} finally {
if (release) {
await release()
}
}
}
/**
* Clean up old resolved permission files
* Called periodically to prevent file accumulation
*
* @param teamName - Team name
* @param maxAgeMs - Maximum age in milliseconds (default: 1 hour)
*/
export async function cleanupOldResolutions(
teamName?: string,
maxAgeMs = 3600000,
): Promise<number> {
const team = teamName || getTeamName()
if (!team) {
return 0
}
const resolvedDir = getResolvedDir(team)
let files: string[]
try {
files = await readdir(resolvedDir)
} catch (e: unknown) {
const code = getErrnoCode(e)
if (code === 'ENOENT') {
return 0
}
logForDebugging(`[PermissionSync] Failed to cleanup resolutions: ${e}`)
logError(e)
return 0
}
const now = Date.now()
const jsonFiles = files.filter(f => f.endsWith('.json'))
const cleanupResults = await Promise.all(
jsonFiles.map(async file => {
const filePath = join(resolvedDir, file)
try {
const content = await readFile(filePath, 'utf-8')
const request = jsonParse(content) as SwarmPermissionRequest
// Check if the resolution is old enough to clean up
// Use >= to handle edge case where maxAgeMs is 0 (clean up everything)
const resolvedAt = request.resolvedAt || request.createdAt
if (now - resolvedAt >= maxAgeMs) {
await unlink(filePath)
logForDebugging(`[PermissionSync] Cleaned up old resolution: ${file}`)
return 1
}
return 0
} catch {
// If we can't parse it, clean it up anyway
try {
await unlink(filePath)
return 1
} catch {
// Ignore deletion errors
return 0
}
}
}),
)
const cleanedCount = cleanupResults.reduce<number>((sum, n) => sum + n, 0)
if (cleanedCount > 0) {
logForDebugging(
`[PermissionSync] Cleaned up ${cleanedCount} old resolutions`,
)
}
return cleanedCount
}
/**
* Legacy response type for worker polling
* Used for backward compatibility with worker integration code
*/
export type PermissionResponse = {
/** ID of the request this responds to */
requestId: string
/** Decision: approved or denied */
decision: 'approved' | 'denied'
/** Timestamp when response was created */
timestamp: string
/** Optional feedback message if denied */
feedback?: string
/** Optional updated input if the resolver modified it */
updatedInput?: Record<string, unknown>
/** Permission updates to apply (e.g., "always allow" rules) */
permissionUpdates?: unknown[]
}
/**
* Poll for a permission response (worker-side convenience function)
* Converts the resolved request into a simpler response format
*
* @returns The permission response, or null if not yet resolved
*/
export async function pollForResponse(
requestId: string,
_agentName?: string,
teamName?: string,
): Promise<PermissionResponse | null> {
const resolved = await readResolvedPermission(requestId, teamName)
if (!resolved) {
return null
}
return {
requestId: resolved.id,
decision: resolved.status === 'approved' ? 'approved' : 'denied',
timestamp: resolved.resolvedAt
? new Date(resolved.resolvedAt).toISOString()
: new Date(resolved.createdAt).toISOString(),
feedback: resolved.feedback,
updatedInput: resolved.updatedInput,
permissionUpdates: resolved.permissionUpdates,
}
}
/**
* Remove a worker's response after processing
* This is an alias for deleteResolvedPermission for backward compatibility
*/
export async function removeWorkerResponse(
requestId: string,
_agentName?: string,
teamName?: string,
): Promise<void> {
await deleteResolvedPermission(requestId, teamName)
}
/** /**
* Check if the current agent is a team leader * Check if the current agent is a team leader
*/ */
@@ -600,46 +167,6 @@ export function isSwarmWorker(): boolean {
return !!teamName && !!agentId && !isTeamLeader() return !!teamName && !!agentId && !isTeamLeader()
} }
/**
* Delete a resolved permission file
* Called after a worker has processed the resolution
*/
export async function deleteResolvedPermission(
requestId: string,
teamName?: string,
): Promise<boolean> {
const team = teamName || getTeamName()
if (!team) {
return false
}
const resolvedPath = getResolvedRequestPath(team, requestId)
try {
await unlink(resolvedPath)
logForDebugging(
`[PermissionSync] Deleted resolved permission: ${requestId}`,
)
return true
} catch (e: unknown) {
const code = getErrnoCode(e)
if (code === 'ENOENT') {
return false
}
logForDebugging(
`[PermissionSync] Failed to delete resolved permission: ${e}`,
)
logError(e)
return false
}
}
/**
* Submit a permission request (alias for writePermissionRequest)
* Provided for backward compatibility with worker integration code
*/
export const submitPermissionRequest = writePermissionRequest
// ============================================================================ // ============================================================================
// Mailbox-Based Permission System // Mailbox-Based Permission System
// ============================================================================ // ============================================================================

View File

@@ -66,7 +66,6 @@ export type TeamFile = {
description?: string description?: string
createdAt: number createdAt: number
leadAgentId: string leadAgentId: string
leadSessionId?: string // Legacy field; stripped from persisted configs
hiddenPaneIds?: string[] // Pane IDs that are currently hidden from the UI hiddenPaneIds?: string[] // Pane IDs that are currently hidden from the UI
teamAllowedPaths?: TeamAllowedPath[] // Paths all teammates can edit without asking teamAllowedPaths?: TeamAllowedPath[] // Paths all teammates can edit without asking
members: Array<{ members: Array<{
@@ -81,8 +80,6 @@ export type TeamFile = {
tmuxPaneId: string tmuxPaneId: string
cwd: string cwd: string
worktreePath?: string worktreePath?: string
sessionId?: string // Legacy field; stripped from persisted configs
subscriptions?: string[] // Legacy field; stripped from persisted configs
backendType?: BackendType backendType?: BackendType
isActive?: boolean // false when idle, undefined/true when active isActive?: boolean // false when idle, undefined/true when active
mode?: PermissionMode // Current permission mode for this teammate mode?: PermissionMode // Current permission mode for this teammate