mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +00:00
* fix: bound agent communication memory growth UDS messaging now uses private local capabilities instead of exposing auth tokens through SDK metadata, environment variables, session registry, peer listing, or tool output. The receive path bounds NDJSON frames, response buffers, active clients, and pending inbox bytes, and strips auth metadata before messages enter the prompt queue. Teammate mailboxes now validate file and message sizes, fail closed on corrupt mutation inputs, compact by count and retained bytes, and use stable message identity for in-process acknowledgements. Agent summaries now fork only a bounded recent context using lazy size estimation and content fingerprints instead of retaining or serializing unbounded histories. Constraint: PR #361 was already merged; this branch is based on upstream/main@c2ac9a74. Rejected: Default-disabling COORDINATOR_MODE/TEAMMEM only | explicit feature enablement still hit unbounded paths. Rejected: Persisting UDS auth in SDK/env/session registry | bridge/remote metadata can leak local capability secrets. Rejected: Inline uds #token addresses | observable/tool/classifier paths can reflect raw addresses outside the UDS request frame. Rejected: Positional mailbox marking after compaction | compaction can shift indices across the lock boundary. Confidence: high Scope-risk: moderate Directive: Do not expose UDS capability tokens through SDK messages, environment variables, session registry, peer-list output, or SendMessage result/classifier surfaces. Directive: Do not reintroduce positional mailbox acknowledgements unless compaction is removed or read+mark is atomic under one lock. Tested: bun test src/utils/__tests__/ndjsonFramer.test.ts src/utils/__tests__/udsMessaging.test.ts packages/builtin-tools/src/tools/SendMessageTool/__tests__/udsRecipientSanitization.test.ts Tested: bunx tsc --noEmit --pretty false Tested: bun run lint Tested: bunx biome lint modified src/package files Tested: bun run test:all (3704 pass, 0 fail, 6734 expects) Tested: bun audit (No vulnerabilities found) Tested: bun run build Tested: bun run build:vite Tested: git diff --check Not-tested: End-to-end external UDS client driving a full production headless model turn. * fix: harden bounded agent communication review fixes CodeRabbit and Codecov surfaced real gaps in UDS framing, peer discovery, mailbox retention, and summary context coverage. This tightens those paths without suppressing review or coverage signals. Constraint: PR #369 must address CodeRabbit and Codecov findings without warning suppression or fake fallbacks Rejected: Suppress Codecov or CodeRabbit warnings | leaves real receive-path and test-isolation gaps Rejected: Add unreachable feature-gated tests | bun:bundle keeps those branches compile-time gated in local tests Confidence: high Scope-risk: moderate Directive: Keep UDS auth-token rejection outside feature flags; do not reintroduce inline token fallbacks Tested: bun test --coverage --coverage-reporter lcov --coverage-dir coverage; bun run test:all; bun run lint; bun run build; bun run build:vite; bun audit; git diff --cached --check Not-tested: Remote Codecov/CodeRabbit refreshed reports until pushed * fix: prevent agent communication bounds from hiding CI regressions Tighten the UDS auth, framing, and response-reader boundaries while keeping the AgentSummary lifecycle covered so Codecov and CI fail on real regressions instead of missing coverage. The poorMode settings mock mirrors unrelated real settings defaults to avoid Bun mock retention changing later permission tests. Constraint: PR #369 must fix Codecov/CI precisely without warning suppression, fallback masking, or mock pollution Rejected: Delete AgentSummary lifecycle coverage | would hide Codecov loss and stale-summary behavior Rejected: Store inline UDS rejection in a hidden input sentinel | cloned observable inputs can drop it and bypass rejection Rejected: Ignore malformed UDS frames until timeout | leaves client slots and SendMessage calls open to exhaustion Confidence: high Scope-risk: moderate Directive: Keep empty #token= markers rejected; do not require a non-empty token value in hasInlineUdsToken Tested: bun test packages/builtin-tools/src/tools/SendMessageTool/__tests__/udsRecipientSanitization.test.ts src/utils/__tests__/udsMessaging.test.ts src/utils/__tests__/udsResponseReader.test.ts src/utils/__tests__/ndjsonFramer.test.ts Tested: bunx tsc --noEmit --pretty false Tested: bun run lint Tested: bun test --coverage --coverage-reporter lcov --coverage-dir coverage Tested: bun run test:all Tested: bun audit Tested: bun run build Tested: bun run build:vite Not-tested: GitHub-hosted Codecov upload until pushed PR checks rerun --------- Co-authored-by: unraid <local@unraid.local>
1462 lines
41 KiB
TypeScript
1462 lines
41 KiB
TypeScript
/**
|
|
* Teammate Mailbox - File-based messaging system for agent swarms
|
|
*
|
|
* Each teammate has an inbox file at .claude/teams/{team_name}/inboxes/{agent_name}.json
|
|
* Other teammates can write messages to it, and the recipient sees them as attachments.
|
|
*
|
|
* Note: Inboxes are keyed by agent name within a team.
|
|
*/
|
|
|
|
import { randomBytes } from 'crypto'
|
|
import { mkdir, readFile, rename, stat, unlink, writeFile } from 'fs/promises'
|
|
import { join } from 'path'
|
|
import { z } from 'zod/v4'
|
|
import { TEAMMATE_MESSAGE_TAG } from '../constants/xml.js'
|
|
import { PermissionModeSchema } from '../entrypoints/sdk/coreSchemas.js'
|
|
import { SEND_MESSAGE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SendMessageTool/constants.js'
|
|
import type { Message } from '../types/message.js'
|
|
import { generateRequestId } from './agentId.js'
|
|
import { count } from './array.js'
|
|
import { logForDebugging } from './debug.js'
|
|
import { getTeamsDir } from './envUtils.js'
|
|
import { getErrnoCode } from './errors.js'
|
|
import { lazySchema } from './lazySchema.js'
|
|
import * as lockfile from './lockfile.js'
|
|
import { logError } from './log.js'
|
|
import { jsonParse, jsonStringify } from './slowOperations.js'
|
|
import type { BackendType } from './swarm/backends/types.js'
|
|
import { TEAM_LEAD_NAME } from './swarm/constants.js'
|
|
import { sanitizePathComponent } from './tasks.js'
|
|
import { getAgentName, getTeammateColor, getTeamName } from './teammate.js'
|
|
|
|
// Lock options: retry with backoff so concurrent callers (multiple Claudes
|
|
// in a swarm) wait for the lock instead of failing immediately. The sync
|
|
// lockSync API blocked the event loop; the async API needs explicit retries
|
|
// to achieve the same serialization semantics.
|
|
const LOCK_OPTIONS = {
|
|
retries: {
|
|
retries: 10,
|
|
minTimeout: 5,
|
|
maxTimeout: 100,
|
|
},
|
|
}
|
|
|
|
export const MAX_MAILBOX_MESSAGES = 1_000
|
|
export const MAX_READ_MAILBOX_MESSAGES = 200
|
|
export const MAX_UNREAD_PROTOCOL_MAILBOX_MESSAGES = 2_000
|
|
export const MAX_MAILBOX_MESSAGE_TEXT_BYTES = 64 * 1024
|
|
export const MAX_MAILBOX_RETAINED_BYTES = 2 * 1024 * 1024
|
|
export const MAX_MAILBOX_FILE_BYTES = 4 * 1024 * 1024
|
|
|
|
export type TeammateMessage = {
|
|
from: string
|
|
text: string
|
|
timestamp: string
|
|
read: boolean
|
|
color?: string // Sender's assigned color (e.g., 'red', 'blue', 'green')
|
|
summary?: string // 5-10 word summary shown as preview in the UI
|
|
}
|
|
|
|
function isJsonLikeMessage(text: string): boolean {
|
|
const trimmed = text.trimStart()
|
|
return trimmed.startsWith('{') || trimmed.startsWith('[')
|
|
}
|
|
|
|
function shouldRetainUnreadAsProtocolMessage(
|
|
message: TeammateMessage,
|
|
): boolean {
|
|
if (message.read) return false
|
|
if (isStructuredProtocolMessage(message.text)) return true
|
|
if (!isJsonLikeMessage(message.text)) return false
|
|
|
|
try {
|
|
const parsed = jsonParse(message.text)
|
|
return Boolean(
|
|
parsed &&
|
|
typeof parsed === 'object' &&
|
|
'type' in (parsed as Record<string, unknown>),
|
|
)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function sameMailboxMessage(a: TeammateMessage, b: TeammateMessage): boolean {
|
|
return a.from === b.from && a.timestamp === b.timestamp && a.text === b.text
|
|
}
|
|
|
|
function mailboxMessageStorageBytes(message: TeammateMessage): number {
|
|
return Buffer.byteLength(jsonStringify(message), 'utf8')
|
|
}
|
|
|
|
function assertMailboxMessageSize(message: TeammateMessage): void {
|
|
const textBytes = Buffer.byteLength(message.text, 'utf8')
|
|
if (textBytes > MAX_MAILBOX_MESSAGE_TEXT_BYTES) {
|
|
throw new Error(
|
|
`Mailbox message text exceeds ${MAX_MAILBOX_MESSAGE_TEXT_BYTES} bytes`,
|
|
)
|
|
}
|
|
}
|
|
|
|
function toMailboxMessage(value: unknown): TeammateMessage {
|
|
if (!value || typeof value !== 'object') {
|
|
throw new Error('Invalid mailbox message: expected object')
|
|
}
|
|
const record = value as Record<string, unknown>
|
|
if (
|
|
typeof record.from !== 'string' ||
|
|
typeof record.text !== 'string' ||
|
|
typeof record.timestamp !== 'string' ||
|
|
typeof record.read !== 'boolean'
|
|
) {
|
|
throw new Error('Invalid mailbox message shape')
|
|
}
|
|
const message: TeammateMessage = {
|
|
from: record.from,
|
|
text: record.text,
|
|
timestamp: record.timestamp,
|
|
read: record.read,
|
|
...(typeof record.color === 'string' ? { color: record.color } : {}),
|
|
...(typeof record.summary === 'string' ? { summary: record.summary } : {}),
|
|
}
|
|
assertMailboxMessageSize(message)
|
|
return message
|
|
}
|
|
|
|
function parseMailboxMessages(content: string): TeammateMessage[] {
|
|
const parsed = jsonParse(content)
|
|
if (!Array.isArray(parsed)) {
|
|
throw new Error('Invalid mailbox file: expected message array')
|
|
}
|
|
return parsed.map(toMailboxMessage)
|
|
}
|
|
|
|
async function readMailboxFile(inboxPath: string): Promise<string> {
|
|
const info = await stat(inboxPath)
|
|
if (info.size > MAX_MAILBOX_FILE_BYTES) {
|
|
throw new Error(
|
|
`Mailbox file exceeds ${MAX_MAILBOX_FILE_BYTES} bytes: ${inboxPath}`,
|
|
)
|
|
}
|
|
return readFile(inboxPath, 'utf-8')
|
|
}
|
|
|
|
async function readMailboxForMutation(
|
|
agentName: string,
|
|
teamName?: string,
|
|
): Promise<TeammateMessage[]> {
|
|
const inboxPath = getInboxPath(agentName, teamName)
|
|
return parseMailboxMessages(await readMailboxFile(inboxPath))
|
|
}
|
|
|
|
async function writeMailboxAtomic(
|
|
inboxPath: string,
|
|
content: string,
|
|
): Promise<void> {
|
|
const bytes = Buffer.byteLength(content, 'utf8')
|
|
if (bytes > MAX_MAILBOX_FILE_BYTES) {
|
|
throw new Error(
|
|
`Compacted mailbox still exceeds ${MAX_MAILBOX_FILE_BYTES} bytes`,
|
|
)
|
|
}
|
|
const tempPath = `${inboxPath}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`
|
|
try {
|
|
await writeFile(tempPath, content, 'utf-8')
|
|
await rename(tempPath, inboxPath)
|
|
} catch (error) {
|
|
await unlink(tempPath).catch(() => undefined)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
export function compactMailboxMessages(
|
|
messages: TeammateMessage[],
|
|
limits: {
|
|
maxMessages?: number
|
|
maxReadMessages?: number
|
|
maxUnreadProtocolMessages?: number
|
|
maxRetainedBytes?: number
|
|
} = {},
|
|
): TeammateMessage[] {
|
|
const maxMessages = limits.maxMessages ?? MAX_MAILBOX_MESSAGES
|
|
const maxReadMessages = limits.maxReadMessages ?? MAX_READ_MAILBOX_MESSAGES
|
|
const maxUnreadProtocolMessages =
|
|
limits.maxUnreadProtocolMessages ?? MAX_UNREAD_PROTOCOL_MAILBOX_MESSAGES
|
|
const maxRetainedBytes = limits.maxRetainedBytes ?? MAX_MAILBOX_RETAINED_BYTES
|
|
|
|
if (
|
|
maxRetainedBytes <= 0 ||
|
|
(maxMessages <= 0 && maxUnreadProtocolMessages <= 0)
|
|
) {
|
|
return []
|
|
}
|
|
|
|
const keepIndexes = new Set<number>()
|
|
let retainedBytes = 0
|
|
let keptUnreadProtocolMessages = 0
|
|
const tryKeep = (index: number): boolean => {
|
|
if (keepIndexes.has(index)) return true
|
|
const message = messages[index]
|
|
if (!message) return false
|
|
const bytes = mailboxMessageStorageBytes(message)
|
|
if (bytes > maxRetainedBytes || retainedBytes + bytes > maxRetainedBytes) {
|
|
return false
|
|
}
|
|
keepIndexes.add(index)
|
|
retainedBytes += bytes
|
|
return true
|
|
}
|
|
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
const message = messages[i]
|
|
if (!message || !shouldRetainUnreadAsProtocolMessage(message)) continue
|
|
if (keptUnreadProtocolMessages >= maxUnreadProtocolMessages) continue
|
|
if (tryKeep(i)) keptUnreadProtocolMessages++
|
|
}
|
|
|
|
let keptNonProtocolMessages = 0
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
if (keptNonProtocolMessages >= maxMessages) break
|
|
const message = messages[i]
|
|
if (
|
|
message &&
|
|
!message.read &&
|
|
!shouldRetainUnreadAsProtocolMessage(message)
|
|
) {
|
|
if (tryKeep(i)) keptNonProtocolMessages++
|
|
}
|
|
}
|
|
|
|
let keptReadMessages = 0
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
if (keptNonProtocolMessages >= maxMessages) break
|
|
if (keptReadMessages >= maxReadMessages) break
|
|
const message = messages[i]
|
|
if (message?.read) {
|
|
if (tryKeep(i)) {
|
|
keptReadMessages++
|
|
keptNonProtocolMessages++
|
|
}
|
|
}
|
|
}
|
|
|
|
return messages.filter((_message, index) => keepIndexes.has(index))
|
|
}
|
|
|
|
function logUnreadMailboxEvictions(
|
|
original: TeammateMessage[],
|
|
compacted: TeammateMessage[],
|
|
context: string,
|
|
): void {
|
|
const kept = new Set(compacted)
|
|
const unreadEvicted = original.filter(message => {
|
|
return !message.read && !kept.has(message)
|
|
})
|
|
if (unreadEvicted.length === 0) return
|
|
|
|
const protocolEvicted = count(unreadEvicted, message =>
|
|
shouldRetainUnreadAsProtocolMessage(message),
|
|
)
|
|
logError(
|
|
new Error(
|
|
`[TeammateMailbox] Compacted ${unreadEvicted.length} unread message(s) in ${context}; protocol_or_unknown=${protocolEvicted}`,
|
|
),
|
|
)
|
|
}
|
|
|
|
async function writeCompactedMailbox(
|
|
inboxPath: string,
|
|
messages: TeammateMessage[],
|
|
context: string,
|
|
): Promise<void> {
|
|
const compacted = compactMailboxMessages(messages)
|
|
logUnreadMailboxEvictions(messages, compacted, context)
|
|
await writeMailboxAtomic(inboxPath, jsonStringify(compacted, null, 2))
|
|
}
|
|
|
|
/**
|
|
* Get the path to a teammate's inbox file
|
|
* Structure: ~/.claude/teams/{team_name}/inboxes/{agent_name}.json
|
|
*/
|
|
export function getInboxPath(agentName: string, teamName?: string): string {
|
|
const team = teamName || getTeamName() || 'default'
|
|
const safeTeam = sanitizePathComponent(team)
|
|
const safeAgentName = sanitizePathComponent(agentName)
|
|
const inboxDir = join(getTeamsDir(), safeTeam, 'inboxes')
|
|
const fullPath = join(inboxDir, `${safeAgentName}.json`)
|
|
logForDebugging(
|
|
`[TeammateMailbox] getInboxPath: agent=${agentName}, team=${team}, fullPath=${fullPath}`,
|
|
)
|
|
return fullPath
|
|
}
|
|
|
|
/**
|
|
* Ensure the inbox directory exists for a team
|
|
*/
|
|
async function ensureInboxDir(teamName?: string): Promise<void> {
|
|
const team = teamName || getTeamName() || 'default'
|
|
const safeTeam = sanitizePathComponent(team)
|
|
const inboxDir = join(getTeamsDir(), safeTeam, 'inboxes')
|
|
await mkdir(inboxDir, { recursive: true })
|
|
logForDebugging(`[TeammateMailbox] Ensured inbox directory: ${inboxDir}`)
|
|
}
|
|
|
|
/**
|
|
* Read all messages from a teammate's inbox
|
|
* @param agentName - The agent name (not UUID) to read inbox for
|
|
* @param teamName - Optional team name (defaults to CLAUDE_CODE_TEAM_NAME env var or 'default')
|
|
*/
|
|
export async function readMailbox(
|
|
agentName: string,
|
|
teamName?: string,
|
|
): Promise<TeammateMessage[]> {
|
|
const inboxPath = getInboxPath(agentName, teamName)
|
|
logForDebugging(`[TeammateMailbox] readMailbox: path=${inboxPath}`)
|
|
|
|
try {
|
|
const messages = parseMailboxMessages(await readMailboxFile(inboxPath))
|
|
logForDebugging(
|
|
`[TeammateMailbox] readMailbox: read ${messages.length} message(s)`,
|
|
)
|
|
return messages
|
|
} catch (error) {
|
|
const code = getErrnoCode(error)
|
|
if (code === 'ENOENT') {
|
|
logForDebugging(`[TeammateMailbox] readMailbox: file does not exist`)
|
|
return []
|
|
}
|
|
logForDebugging(`Failed to read inbox for ${agentName}: ${error}`)
|
|
logError(error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read only unread messages from a teammate's inbox
|
|
* @param agentName - The agent name (not UUID) to read inbox for
|
|
* @param teamName - Optional team name
|
|
*/
|
|
export async function readUnreadMessages(
|
|
agentName: string,
|
|
teamName?: string,
|
|
): Promise<TeammateMessage[]> {
|
|
const messages = await readMailbox(agentName, teamName)
|
|
const unread = messages.filter(m => !m.read)
|
|
logForDebugging(
|
|
`[TeammateMailbox] readUnreadMessages: ${unread.length} unread of ${messages.length} total`,
|
|
)
|
|
return unread
|
|
}
|
|
|
|
/**
|
|
* Write a message to a teammate's inbox
|
|
* Uses file locking to prevent race conditions when multiple agents write concurrently
|
|
* @param recipientName - The recipient's agent name (not UUID)
|
|
* @param message - The message to write
|
|
* @param teamName - Optional team name
|
|
*/
|
|
export async function writeToMailbox(
|
|
recipientName: string,
|
|
message: Omit<TeammateMessage, 'read'>,
|
|
teamName?: string,
|
|
): Promise<void> {
|
|
await ensureInboxDir(teamName)
|
|
|
|
const inboxPath = getInboxPath(recipientName, teamName)
|
|
const lockFilePath = `${inboxPath}.lock`
|
|
|
|
logForDebugging(
|
|
`[TeammateMailbox] writeToMailbox: recipient=${recipientName}, from=${message.from}, path=${inboxPath}`,
|
|
)
|
|
|
|
// Ensure the inbox file exists before locking (proper-lockfile requires the file to exist)
|
|
try {
|
|
await writeFile(inboxPath, '[]', { encoding: 'utf-8', flag: 'wx' })
|
|
logForDebugging(`[TeammateMailbox] writeToMailbox: created new inbox file`)
|
|
} catch (error) {
|
|
const code = getErrnoCode(error)
|
|
if (code !== 'EEXIST') {
|
|
logForDebugging(
|
|
`[TeammateMailbox] writeToMailbox: failed to create inbox file: ${error}`,
|
|
)
|
|
logError(error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
let release: (() => Promise<void>) | undefined
|
|
try {
|
|
release = await lockfile.lock(inboxPath, {
|
|
lockfilePath: lockFilePath,
|
|
...LOCK_OPTIONS,
|
|
})
|
|
|
|
// Re-read messages after acquiring lock to get the latest state
|
|
const messages = await readMailboxForMutation(recipientName, teamName)
|
|
|
|
const newMessage = toMailboxMessage({
|
|
...message,
|
|
read: false,
|
|
})
|
|
|
|
messages.push(newMessage)
|
|
|
|
await writeCompactedMailbox(inboxPath, messages, 'writeToMailbox')
|
|
logForDebugging(
|
|
`[TeammateMailbox] Wrote message to ${recipientName}'s inbox from ${message.from}`,
|
|
)
|
|
} catch (error) {
|
|
logForDebugging(`Failed to write to inbox for ${recipientName}: ${error}`)
|
|
logError(error)
|
|
throw error
|
|
} finally {
|
|
if (release) {
|
|
await release()
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mark a specific message in a teammate's inbox as read by index
|
|
* Uses file locking to prevent race conditions
|
|
* @param agentName - The agent name to mark message as read for
|
|
* @param teamName - Optional team name
|
|
* @param messageIndex - Index of the message to mark as read
|
|
*/
|
|
export async function markMessageAsReadByIndex(
|
|
agentName: string,
|
|
teamName: string | undefined,
|
|
messageIndex: number,
|
|
): Promise<void> {
|
|
const inboxPath = getInboxPath(agentName, teamName)
|
|
logForDebugging(
|
|
`[TeammateMailbox] markMessageAsReadByIndex called: agentName=${agentName}, teamName=${teamName}, index=${messageIndex}, path=${inboxPath}`,
|
|
)
|
|
|
|
const lockFilePath = `${inboxPath}.lock`
|
|
|
|
let release: (() => Promise<void>) | undefined
|
|
try {
|
|
logForDebugging(
|
|
`[TeammateMailbox] markMessageAsReadByIndex: acquiring lock...`,
|
|
)
|
|
release = await lockfile.lock(inboxPath, {
|
|
lockfilePath: lockFilePath,
|
|
...LOCK_OPTIONS,
|
|
})
|
|
logForDebugging(`[TeammateMailbox] markMessageAsReadByIndex: lock acquired`)
|
|
|
|
// Re-read messages after acquiring lock to get the latest state
|
|
const messages = await readMailboxForMutation(agentName, teamName)
|
|
logForDebugging(
|
|
`[TeammateMailbox] markMessageAsReadByIndex: read ${messages.length} messages after lock`,
|
|
)
|
|
|
|
if (messageIndex < 0 || messageIndex >= messages.length) {
|
|
logForDebugging(
|
|
`[TeammateMailbox] markMessageAsReadByIndex: index ${messageIndex} out of bounds (${messages.length} messages)`,
|
|
)
|
|
return
|
|
}
|
|
|
|
const message = messages[messageIndex]
|
|
if (!message || message.read) {
|
|
logForDebugging(
|
|
`[TeammateMailbox] markMessageAsReadByIndex: message already read or missing`,
|
|
)
|
|
return
|
|
}
|
|
|
|
messages[messageIndex] = { ...message, read: true }
|
|
|
|
await writeCompactedMailbox(inboxPath, messages, 'markMessageAsReadByIndex')
|
|
logForDebugging(
|
|
`[TeammateMailbox] markMessageAsReadByIndex: marked message at index ${messageIndex} as read`,
|
|
)
|
|
} catch (error) {
|
|
const code = getErrnoCode(error)
|
|
if (code === 'ENOENT') {
|
|
logForDebugging(
|
|
`[TeammateMailbox] markMessageAsReadByIndex: file does not exist at ${inboxPath}`,
|
|
)
|
|
return
|
|
}
|
|
logForDebugging(
|
|
`[TeammateMailbox] markMessageAsReadByIndex FAILED for ${agentName}: ${error}`,
|
|
)
|
|
logError(error)
|
|
} finally {
|
|
if (release) {
|
|
await release()
|
|
logForDebugging(
|
|
`[TeammateMailbox] markMessageAsReadByIndex: lock released`,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function markMessageAsReadByIdentity(
|
|
agentName: string,
|
|
teamName: string | undefined,
|
|
expectedMessage: TeammateMessage,
|
|
): Promise<boolean> {
|
|
const inboxPath = getInboxPath(agentName, teamName)
|
|
const lockFilePath = `${inboxPath}.lock`
|
|
|
|
let release: (() => Promise<void>) | undefined
|
|
try {
|
|
release = await lockfile.lock(inboxPath, {
|
|
lockfilePath: lockFilePath,
|
|
...LOCK_OPTIONS,
|
|
})
|
|
|
|
const messages = await readMailboxForMutation(agentName, teamName)
|
|
const messageIndex = messages.findIndex(message => {
|
|
return !message.read && sameMailboxMessage(message, expectedMessage)
|
|
})
|
|
if (messageIndex < 0) return false
|
|
|
|
messages[messageIndex] = { ...messages[messageIndex]!, read: true }
|
|
await writeCompactedMailbox(
|
|
inboxPath,
|
|
messages,
|
|
'markMessageAsReadByIdentity',
|
|
)
|
|
return true
|
|
} catch (error) {
|
|
const code = getErrnoCode(error)
|
|
if (code === 'ENOENT') return false
|
|
logError(error)
|
|
return false
|
|
} finally {
|
|
if (release) {
|
|
await release()
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mark all messages in a teammate's inbox as read
|
|
* Uses file locking to prevent race conditions
|
|
* @param agentName - The agent name to mark messages as read for
|
|
* @param teamName - Optional team name
|
|
*/
|
|
export async function markMessagesAsRead(
|
|
agentName: string,
|
|
teamName?: string,
|
|
): Promise<void> {
|
|
const inboxPath = getInboxPath(agentName, teamName)
|
|
logForDebugging(
|
|
`[TeammateMailbox] markMessagesAsRead called: agentName=${agentName}, teamName=${teamName}, path=${inboxPath}`,
|
|
)
|
|
|
|
const lockFilePath = `${inboxPath}.lock`
|
|
|
|
let release: (() => Promise<void>) | undefined
|
|
try {
|
|
logForDebugging(`[TeammateMailbox] markMessagesAsRead: acquiring lock...`)
|
|
release = await lockfile.lock(inboxPath, {
|
|
lockfilePath: lockFilePath,
|
|
...LOCK_OPTIONS,
|
|
})
|
|
logForDebugging(`[TeammateMailbox] markMessagesAsRead: lock acquired`)
|
|
|
|
// Re-read messages after acquiring lock to get the latest state
|
|
const messages = await readMailboxForMutation(agentName, teamName)
|
|
logForDebugging(
|
|
`[TeammateMailbox] markMessagesAsRead: read ${messages.length} messages after lock`,
|
|
)
|
|
|
|
if (messages.length === 0) {
|
|
logForDebugging(
|
|
`[TeammateMailbox] markMessagesAsRead: no messages to mark`,
|
|
)
|
|
return
|
|
}
|
|
|
|
const unreadCount = count(messages, m => !m.read)
|
|
logForDebugging(
|
|
`[TeammateMailbox] markMessagesAsRead: ${unreadCount} unread of ${messages.length} total`,
|
|
)
|
|
|
|
// messages comes from jsonParse — fresh, unshared objects safe to mutate
|
|
for (const m of messages) m.read = true
|
|
|
|
await writeCompactedMailbox(inboxPath, messages, 'markMessagesAsRead')
|
|
logForDebugging(
|
|
`[TeammateMailbox] markMessagesAsRead: WROTE ${unreadCount} message(s) as read to ${inboxPath}`,
|
|
)
|
|
} catch (error) {
|
|
const code = getErrnoCode(error)
|
|
if (code === 'ENOENT') {
|
|
logForDebugging(
|
|
`[TeammateMailbox] markMessagesAsRead: file does not exist at ${inboxPath}`,
|
|
)
|
|
return
|
|
}
|
|
logForDebugging(
|
|
`[TeammateMailbox] markMessagesAsRead FAILED for ${agentName}: ${error}`,
|
|
)
|
|
logError(error)
|
|
} finally {
|
|
if (release) {
|
|
await release()
|
|
logForDebugging(`[TeammateMailbox] markMessagesAsRead: lock released`)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear a teammate's inbox (delete all messages)
|
|
* @param agentName - The agent name to clear inbox for
|
|
* @param teamName - Optional team name
|
|
*/
|
|
export async function clearMailbox(
|
|
agentName: string,
|
|
teamName?: string,
|
|
): Promise<void> {
|
|
const inboxPath = getInboxPath(agentName, teamName)
|
|
|
|
try {
|
|
// flag 'r+' throws ENOENT if the file doesn't exist, so we don't
|
|
// accidentally create an inbox file that wasn't there.
|
|
await writeFile(inboxPath, '[]', { encoding: 'utf-8', flag: 'r+' })
|
|
logForDebugging(`[TeammateMailbox] Cleared inbox for ${agentName}`)
|
|
} catch (error) {
|
|
const code = getErrnoCode(error)
|
|
if (code === 'ENOENT') {
|
|
return
|
|
}
|
|
logForDebugging(`Failed to clear inbox for ${agentName}: ${error}`)
|
|
logError(error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format teammate messages as XML for attachment display
|
|
*/
|
|
export function formatTeammateMessages(
|
|
messages: Array<{
|
|
from: string
|
|
text: string
|
|
timestamp: string
|
|
color?: string
|
|
summary?: string
|
|
}>,
|
|
): string {
|
|
return messages
|
|
.map(m => {
|
|
const colorAttr = m.color ? ` color="${m.color}"` : ''
|
|
const summaryAttr = m.summary ? ` summary="${m.summary}"` : ''
|
|
return `<${TEAMMATE_MESSAGE_TAG} teammate_id="${m.from}"${colorAttr}${summaryAttr}>\n${m.text}\n</${TEAMMATE_MESSAGE_TAG}>`
|
|
})
|
|
.join('\n\n')
|
|
}
|
|
|
|
/**
|
|
* Structured message sent when a teammate becomes idle (via Stop hook)
|
|
*/
|
|
export type IdleNotificationMessage = {
|
|
type: 'idle_notification'
|
|
from: string
|
|
timestamp: string
|
|
/** Why the agent went idle */
|
|
idleReason?: 'available' | 'interrupted' | 'failed'
|
|
/** Brief summary of the last DM sent this turn (if any) */
|
|
summary?: string
|
|
completedTaskId?: string
|
|
completedStatus?: 'resolved' | 'blocked' | 'failed'
|
|
failureReason?: string
|
|
}
|
|
|
|
/**
|
|
* Creates an idle notification message to send to the team leader
|
|
*/
|
|
export function createIdleNotification(
|
|
agentId: string,
|
|
options?: {
|
|
idleReason?: IdleNotificationMessage['idleReason']
|
|
summary?: string
|
|
completedTaskId?: string
|
|
completedStatus?: 'resolved' | 'blocked' | 'failed'
|
|
failureReason?: string
|
|
},
|
|
): IdleNotificationMessage {
|
|
return {
|
|
type: 'idle_notification',
|
|
from: agentId,
|
|
timestamp: new Date().toISOString(),
|
|
idleReason: options?.idleReason,
|
|
summary: options?.summary,
|
|
completedTaskId: options?.completedTaskId,
|
|
completedStatus: options?.completedStatus,
|
|
failureReason: options?.failureReason,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if a message text contains an idle notification
|
|
*/
|
|
export function isIdleNotification(
|
|
messageText: string,
|
|
): IdleNotificationMessage | null {
|
|
try {
|
|
const parsed = jsonParse(messageText)
|
|
if (parsed && parsed.type === 'idle_notification') {
|
|
return parsed as IdleNotificationMessage
|
|
}
|
|
} catch {
|
|
// Not JSON or not a valid idle notification
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Permission request message sent from worker to leader via mailbox.
|
|
* Field names align with SDK `can_use_tool` (snake_case).
|
|
*/
|
|
export type PermissionRequestMessage = {
|
|
type: 'permission_request'
|
|
request_id: string
|
|
agent_id: string
|
|
tool_name: string
|
|
tool_use_id: string
|
|
description: string
|
|
input: Record<string, unknown>
|
|
permission_suggestions: unknown[]
|
|
}
|
|
|
|
/**
|
|
* Permission response message sent from leader to worker via mailbox.
|
|
* Shape mirrors SDK ControlResponseSchema / ControlErrorResponseSchema.
|
|
*/
|
|
export type PermissionResponseMessage =
|
|
| {
|
|
type: 'permission_response'
|
|
request_id: string
|
|
subtype: 'success'
|
|
response?: {
|
|
updated_input?: Record<string, unknown>
|
|
permission_updates?: unknown[]
|
|
}
|
|
}
|
|
| {
|
|
type: 'permission_response'
|
|
request_id: string
|
|
subtype: 'error'
|
|
error: string
|
|
}
|
|
|
|
/**
|
|
* Creates a permission request message to send to the team leader
|
|
*/
|
|
export function createPermissionRequestMessage(params: {
|
|
request_id: string
|
|
agent_id: string
|
|
tool_name: string
|
|
tool_use_id: string
|
|
description: string
|
|
input: Record<string, unknown>
|
|
permission_suggestions?: unknown[]
|
|
}): PermissionRequestMessage {
|
|
return {
|
|
type: 'permission_request',
|
|
request_id: params.request_id,
|
|
agent_id: params.agent_id,
|
|
tool_name: params.tool_name,
|
|
tool_use_id: params.tool_use_id,
|
|
description: params.description,
|
|
input: params.input,
|
|
permission_suggestions: params.permission_suggestions || [],
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a permission response message to send back to a worker
|
|
*/
|
|
export function createPermissionResponseMessage(params: {
|
|
request_id: string
|
|
subtype: 'success' | 'error'
|
|
error?: string
|
|
updated_input?: Record<string, unknown>
|
|
permission_updates?: unknown[]
|
|
}): PermissionResponseMessage {
|
|
if (params.subtype === 'error') {
|
|
return {
|
|
type: 'permission_response',
|
|
request_id: params.request_id,
|
|
subtype: 'error',
|
|
error: params.error || 'Permission denied',
|
|
}
|
|
}
|
|
return {
|
|
type: 'permission_response',
|
|
request_id: params.request_id,
|
|
subtype: 'success',
|
|
response: {
|
|
updated_input: params.updated_input,
|
|
permission_updates: params.permission_updates,
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if a message text contains a permission request
|
|
*/
|
|
export function isPermissionRequest(
|
|
messageText: string,
|
|
): PermissionRequestMessage | null {
|
|
try {
|
|
const parsed = jsonParse(messageText)
|
|
if (parsed && parsed.type === 'permission_request') {
|
|
return parsed as PermissionRequestMessage
|
|
}
|
|
} catch {
|
|
// Not JSON or not a valid permission request
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Checks if a message text contains a permission response
|
|
*/
|
|
export function isPermissionResponse(
|
|
messageText: string,
|
|
): PermissionResponseMessage | null {
|
|
try {
|
|
const parsed = jsonParse(messageText)
|
|
if (parsed && parsed.type === 'permission_response') {
|
|
return parsed as PermissionResponseMessage
|
|
}
|
|
} catch {
|
|
// Not JSON or not a valid permission response
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Sandbox permission request message sent from worker to leader via mailbox
|
|
* This is triggered when sandbox runtime detects a network access to a non-allowed host
|
|
*/
|
|
export type SandboxPermissionRequestMessage = {
|
|
type: 'sandbox_permission_request'
|
|
/** Unique identifier for this request */
|
|
requestId: string
|
|
/** Worker's CLAUDE_CODE_AGENT_ID */
|
|
workerId: string
|
|
/** Worker's CLAUDE_CODE_AGENT_NAME */
|
|
workerName: string
|
|
/** Worker's CLAUDE_CODE_AGENT_COLOR */
|
|
workerColor?: string
|
|
/** The host pattern requesting network access */
|
|
hostPattern: {
|
|
host: string
|
|
}
|
|
/** Timestamp when request was created */
|
|
createdAt: number
|
|
}
|
|
|
|
/**
|
|
* Sandbox permission response message sent from leader to worker via mailbox
|
|
*/
|
|
export type SandboxPermissionResponseMessage = {
|
|
type: 'sandbox_permission_response'
|
|
/** ID of the request this responds to */
|
|
requestId: string
|
|
/** The host that was approved/denied */
|
|
host: string
|
|
/** Whether the connection is allowed */
|
|
allow: boolean
|
|
/** Timestamp when response was created */
|
|
timestamp: string
|
|
}
|
|
|
|
/**
|
|
* Creates a sandbox permission request message to send to the team leader
|
|
*/
|
|
export function createSandboxPermissionRequestMessage(params: {
|
|
requestId: string
|
|
workerId: string
|
|
workerName: string
|
|
workerColor?: string
|
|
host: string
|
|
}): SandboxPermissionRequestMessage {
|
|
return {
|
|
type: 'sandbox_permission_request',
|
|
requestId: params.requestId,
|
|
workerId: params.workerId,
|
|
workerName: params.workerName,
|
|
workerColor: params.workerColor,
|
|
hostPattern: { host: params.host },
|
|
createdAt: Date.now(),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a sandbox permission response message to send back to a worker
|
|
*/
|
|
export function createSandboxPermissionResponseMessage(params: {
|
|
requestId: string
|
|
host: string
|
|
allow: boolean
|
|
}): SandboxPermissionResponseMessage {
|
|
return {
|
|
type: 'sandbox_permission_response',
|
|
requestId: params.requestId,
|
|
host: params.host,
|
|
allow: params.allow,
|
|
timestamp: new Date().toISOString(),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if a message text contains a sandbox permission request
|
|
*/
|
|
export function isSandboxPermissionRequest(
|
|
messageText: string,
|
|
): SandboxPermissionRequestMessage | null {
|
|
try {
|
|
const parsed = jsonParse(messageText)
|
|
if (parsed && parsed.type === 'sandbox_permission_request') {
|
|
return parsed as SandboxPermissionRequestMessage
|
|
}
|
|
} catch {
|
|
// Not JSON or not a valid sandbox permission request
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Checks if a message text contains a sandbox permission response
|
|
*/
|
|
export function isSandboxPermissionResponse(
|
|
messageText: string,
|
|
): SandboxPermissionResponseMessage | null {
|
|
try {
|
|
const parsed = jsonParse(messageText)
|
|
if (parsed && parsed.type === 'sandbox_permission_response') {
|
|
return parsed as SandboxPermissionResponseMessage
|
|
}
|
|
} catch {
|
|
// Not JSON or not a valid sandbox permission response
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Message sent when a teammate requests plan approval from the team leader
|
|
*/
|
|
export const PlanApprovalRequestMessageSchema = lazySchema(() =>
|
|
z.object({
|
|
type: z.literal('plan_approval_request'),
|
|
from: z.string(),
|
|
timestamp: z.string(),
|
|
planFilePath: z.string(),
|
|
planContent: z.string(),
|
|
requestId: z.string(),
|
|
}),
|
|
)
|
|
|
|
export type PlanApprovalRequestMessage = z.infer<
|
|
ReturnType<typeof PlanApprovalRequestMessageSchema>
|
|
>
|
|
|
|
/**
|
|
* Message sent by the team leader in response to a plan approval request
|
|
*/
|
|
export const PlanApprovalResponseMessageSchema = lazySchema(() =>
|
|
z.object({
|
|
type: z.literal('plan_approval_response'),
|
|
requestId: z.string(),
|
|
approved: z.boolean(),
|
|
feedback: z.string().optional(),
|
|
timestamp: z.string(),
|
|
permissionMode: PermissionModeSchema().optional(),
|
|
}),
|
|
)
|
|
|
|
export type PlanApprovalResponseMessage = z.infer<
|
|
ReturnType<typeof PlanApprovalResponseMessageSchema>
|
|
>
|
|
|
|
/**
|
|
* Shutdown request message sent from leader to teammate via mailbox
|
|
*/
|
|
export const ShutdownRequestMessageSchema = lazySchema(() =>
|
|
z.object({
|
|
type: z.literal('shutdown_request'),
|
|
requestId: z.string(),
|
|
from: z.string(),
|
|
reason: z.string().optional(),
|
|
timestamp: z.string(),
|
|
}),
|
|
)
|
|
|
|
export type ShutdownRequestMessage = z.infer<
|
|
ReturnType<typeof ShutdownRequestMessageSchema>
|
|
>
|
|
|
|
/**
|
|
* Shutdown approved message sent from teammate to leader via mailbox
|
|
*/
|
|
export const ShutdownApprovedMessageSchema = lazySchema(() =>
|
|
z.object({
|
|
type: z.literal('shutdown_approved'),
|
|
requestId: z.string(),
|
|
from: z.string(),
|
|
timestamp: z.string(),
|
|
paneId: z.string().optional(),
|
|
backendType: z.string().optional(),
|
|
}),
|
|
)
|
|
|
|
export type ShutdownApprovedMessage = z.infer<
|
|
ReturnType<typeof ShutdownApprovedMessageSchema>
|
|
>
|
|
|
|
/**
|
|
* Shutdown rejected message sent from teammate to leader via mailbox
|
|
*/
|
|
export const ShutdownRejectedMessageSchema = lazySchema(() =>
|
|
z.object({
|
|
type: z.literal('shutdown_rejected'),
|
|
requestId: z.string(),
|
|
from: z.string(),
|
|
reason: z.string(),
|
|
timestamp: z.string(),
|
|
}),
|
|
)
|
|
|
|
export type ShutdownRejectedMessage = z.infer<
|
|
ReturnType<typeof ShutdownRejectedMessageSchema>
|
|
>
|
|
|
|
/**
|
|
* Creates a shutdown request message to send to a teammate
|
|
*/
|
|
export function createShutdownRequestMessage(params: {
|
|
requestId: string
|
|
from: string
|
|
reason?: string
|
|
}): ShutdownRequestMessage {
|
|
return {
|
|
type: 'shutdown_request',
|
|
requestId: params.requestId,
|
|
from: params.from,
|
|
reason: params.reason,
|
|
timestamp: new Date().toISOString(),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a shutdown approved message to send to the team leader
|
|
*/
|
|
export function createShutdownApprovedMessage(params: {
|
|
requestId: string
|
|
from: string
|
|
paneId?: string
|
|
backendType?: BackendType
|
|
}): ShutdownApprovedMessage {
|
|
return {
|
|
type: 'shutdown_approved',
|
|
requestId: params.requestId,
|
|
from: params.from,
|
|
timestamp: new Date().toISOString(),
|
|
paneId: params.paneId,
|
|
backendType: params.backendType,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a shutdown rejected message to send to the team leader
|
|
*/
|
|
export function createShutdownRejectedMessage(params: {
|
|
requestId: string
|
|
from: string
|
|
reason: string
|
|
}): ShutdownRejectedMessage {
|
|
return {
|
|
type: 'shutdown_rejected',
|
|
requestId: params.requestId,
|
|
from: params.from,
|
|
reason: params.reason,
|
|
timestamp: new Date().toISOString(),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sends a shutdown request to a teammate's mailbox.
|
|
* This is the core logic extracted for reuse by both the tool and UI components.
|
|
*
|
|
* @param targetName - Name of the teammate to send shutdown request to
|
|
* @param teamName - Optional team name (defaults to CLAUDE_CODE_TEAM_NAME env var)
|
|
* @param reason - Optional reason for the shutdown request
|
|
* @returns The request ID and target name
|
|
*/
|
|
export async function sendShutdownRequestToMailbox(
|
|
targetName: string,
|
|
teamName?: string,
|
|
reason?: string,
|
|
): Promise<{ requestId: string; target: string }> {
|
|
const resolvedTeamName = teamName || getTeamName()
|
|
|
|
// Get sender name (supports in-process teammates via AsyncLocalStorage)
|
|
const senderName = getAgentName() || TEAM_LEAD_NAME
|
|
|
|
// Generate a deterministic request ID for this shutdown request
|
|
const requestId = generateRequestId('shutdown', targetName)
|
|
|
|
// Create and send the shutdown request message
|
|
const shutdownMessage = createShutdownRequestMessage({
|
|
requestId,
|
|
from: senderName,
|
|
reason,
|
|
})
|
|
|
|
await writeToMailbox(
|
|
targetName,
|
|
{
|
|
from: senderName,
|
|
text: jsonStringify(shutdownMessage),
|
|
timestamp: new Date().toISOString(),
|
|
color: getTeammateColor(),
|
|
},
|
|
resolvedTeamName,
|
|
)
|
|
|
|
return { requestId, target: targetName }
|
|
}
|
|
|
|
/**
|
|
* Checks if a message text contains a shutdown request
|
|
*/
|
|
export function isShutdownRequest(
|
|
messageText: string,
|
|
): ShutdownRequestMessage | null {
|
|
try {
|
|
const result = ShutdownRequestMessageSchema().safeParse(
|
|
jsonParse(messageText),
|
|
)
|
|
if (result.success) return result.data
|
|
} catch {
|
|
// Not JSON
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Checks if a message text contains a plan approval request
|
|
*/
|
|
export function isPlanApprovalRequest(
|
|
messageText: string,
|
|
): PlanApprovalRequestMessage | null {
|
|
try {
|
|
const result = PlanApprovalRequestMessageSchema().safeParse(
|
|
jsonParse(messageText),
|
|
)
|
|
if (result.success) return result.data
|
|
} catch {
|
|
// Not JSON
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Checks if a message text contains a shutdown approved message
|
|
*/
|
|
export function isShutdownApproved(
|
|
messageText: string,
|
|
): ShutdownApprovedMessage | null {
|
|
try {
|
|
const result = ShutdownApprovedMessageSchema().safeParse(
|
|
jsonParse(messageText),
|
|
)
|
|
if (result.success) return result.data
|
|
} catch {
|
|
// Not JSON
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Checks if a message text contains a shutdown rejected message
|
|
*/
|
|
export function isShutdownRejected(
|
|
messageText: string,
|
|
): ShutdownRejectedMessage | null {
|
|
try {
|
|
const result = ShutdownRejectedMessageSchema().safeParse(
|
|
jsonParse(messageText),
|
|
)
|
|
if (result.success) return result.data
|
|
} catch {
|
|
// Not JSON
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Checks if a message text contains a plan approval response
|
|
*/
|
|
export function isPlanApprovalResponse(
|
|
messageText: string,
|
|
): PlanApprovalResponseMessage | null {
|
|
try {
|
|
const result = PlanApprovalResponseMessageSchema().safeParse(
|
|
jsonParse(messageText),
|
|
)
|
|
if (result.success) return result.data
|
|
} catch {
|
|
// Not JSON
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Task assignment message sent when a task is assigned to a teammate
|
|
*/
|
|
export type TaskAssignmentMessage = {
|
|
type: 'task_assignment'
|
|
taskId: string
|
|
subject: string
|
|
description: string
|
|
assignedBy: string
|
|
timestamp: string
|
|
}
|
|
|
|
/**
|
|
* Checks if a message text contains a task assignment
|
|
*/
|
|
export function isTaskAssignment(
|
|
messageText: string,
|
|
): TaskAssignmentMessage | null {
|
|
try {
|
|
const parsed = jsonParse(messageText)
|
|
if (parsed && parsed.type === 'task_assignment') {
|
|
return parsed as TaskAssignmentMessage
|
|
}
|
|
} catch {
|
|
// Not JSON or not a valid task assignment
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Team permission update message sent from leader to teammates via mailbox
|
|
* Broadcasts a permission update that applies to all teammates
|
|
*/
|
|
export type TeamPermissionUpdateMessage = {
|
|
type: 'team_permission_update'
|
|
/** The permission update to apply */
|
|
permissionUpdate: {
|
|
type: 'addRules'
|
|
rules: Array<{ toolName: string; ruleContent?: string }>
|
|
behavior: 'allow' | 'deny' | 'ask'
|
|
destination: 'session'
|
|
}
|
|
/** The directory path that was allowed */
|
|
directoryPath: string
|
|
/** The tool name this applies to */
|
|
toolName: string
|
|
}
|
|
|
|
/**
|
|
* Checks if a message text contains a team permission update
|
|
*/
|
|
export function isTeamPermissionUpdate(
|
|
messageText: string,
|
|
): TeamPermissionUpdateMessage | null {
|
|
try {
|
|
const parsed = jsonParse(messageText)
|
|
if (parsed && parsed.type === 'team_permission_update') {
|
|
return parsed as TeamPermissionUpdateMessage
|
|
}
|
|
} catch {
|
|
// Not JSON or not a valid team permission update
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Mode set request message sent from leader to teammate via mailbox
|
|
* Uses SDK PermissionModeSchema for validated mode values
|
|
*/
|
|
export const ModeSetRequestMessageSchema = lazySchema(() =>
|
|
z.object({
|
|
type: z.literal('mode_set_request'),
|
|
mode: PermissionModeSchema(),
|
|
from: z.string(),
|
|
}),
|
|
)
|
|
|
|
export type ModeSetRequestMessage = z.infer<
|
|
ReturnType<typeof ModeSetRequestMessageSchema>
|
|
>
|
|
|
|
/**
|
|
* Creates a mode set request message to send to a teammate
|
|
*/
|
|
export function createModeSetRequestMessage(params: {
|
|
mode: string
|
|
from: string
|
|
}): ModeSetRequestMessage {
|
|
return {
|
|
type: 'mode_set_request',
|
|
mode: params.mode as ModeSetRequestMessage['mode'],
|
|
from: params.from,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if a message text contains a mode set request
|
|
*/
|
|
export function isModeSetRequest(
|
|
messageText: string,
|
|
): ModeSetRequestMessage | null {
|
|
try {
|
|
const parsed = ModeSetRequestMessageSchema().safeParse(
|
|
jsonParse(messageText),
|
|
)
|
|
if (parsed.success) {
|
|
return parsed.data
|
|
}
|
|
} catch {
|
|
// Not JSON or not a valid mode set request
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Checks if a message text is a structured protocol message that should be
|
|
* routed by useInboxPoller rather than consumed as raw LLM context.
|
|
*
|
|
* These message types have specific handlers in useInboxPoller that route them
|
|
* to the correct queues (workerPermissions, workerSandboxPermissions, etc.).
|
|
* If getTeammateMailboxAttachments consumes them first, they get bundled as
|
|
* raw text in attachments and never reach their intended handlers.
|
|
*/
|
|
export function isStructuredProtocolMessage(messageText: string): boolean {
|
|
try {
|
|
const parsed = jsonParse(messageText)
|
|
if (!parsed || typeof parsed !== 'object' || !('type' in parsed)) {
|
|
return false
|
|
}
|
|
const type = (parsed as { type: unknown }).type
|
|
return (
|
|
type === 'permission_request' ||
|
|
type === 'permission_response' ||
|
|
type === 'sandbox_permission_request' ||
|
|
type === 'sandbox_permission_response' ||
|
|
type === 'shutdown_request' ||
|
|
type === 'shutdown_approved' ||
|
|
type === 'team_permission_update' ||
|
|
type === 'mode_set_request' ||
|
|
type === 'plan_approval_request' ||
|
|
type === 'plan_approval_response'
|
|
)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Marks only messages matching a predicate as read, leaving others unread.
|
|
* Uses the same file-locking mechanism as markMessagesAsRead.
|
|
*/
|
|
export async function markMessagesAsReadByPredicate(
|
|
agentName: string,
|
|
predicate: (msg: TeammateMessage) => boolean,
|
|
teamName?: string,
|
|
): Promise<void> {
|
|
const inboxPath = getInboxPath(agentName, teamName)
|
|
|
|
const lockFilePath = `${inboxPath}.lock`
|
|
let release: (() => Promise<void>) | undefined
|
|
|
|
try {
|
|
release = await lockfile.lock(inboxPath, {
|
|
lockfilePath: lockFilePath,
|
|
...LOCK_OPTIONS,
|
|
})
|
|
|
|
const messages = await readMailboxForMutation(agentName, teamName)
|
|
if (messages.length === 0) {
|
|
return
|
|
}
|
|
|
|
const updatedMessages = messages.map(m =>
|
|
!m.read && predicate(m) ? { ...m, read: true } : m,
|
|
)
|
|
|
|
await writeCompactedMailbox(
|
|
inboxPath,
|
|
updatedMessages,
|
|
'markMessagesAsReadByPredicate',
|
|
)
|
|
} catch (error) {
|
|
const code = getErrnoCode(error)
|
|
if (code === 'ENOENT') {
|
|
return
|
|
}
|
|
logError(error)
|
|
} finally {
|
|
if (release) {
|
|
try {
|
|
await release()
|
|
} catch {
|
|
// Lock may have already been released
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extracts a "[to {name}] {summary}" string from the last assistant message
|
|
* if it ended with a SendMessage tool_use targeting a peer (not the team lead).
|
|
* Returns undefined when the turn didn't end with a peer DM.
|
|
*/
|
|
export function getLastPeerDmSummary(messages: Message[]): string | undefined {
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
const msg = messages[i]
|
|
if (!msg) continue
|
|
|
|
// Stop at wake-up boundary: a user prompt (string content), not tool results (array content)
|
|
if (msg.type === 'user' && typeof msg.message!.content === 'string') {
|
|
break
|
|
}
|
|
|
|
if (msg.type !== 'assistant') continue
|
|
const content = msg.message?.content
|
|
if (!Array.isArray(content)) continue
|
|
for (const block of content) {
|
|
if (typeof block === 'string') continue
|
|
const b = block as unknown as {
|
|
type: string
|
|
name?: string
|
|
input?: Record<string, unknown>
|
|
[key: string]: unknown
|
|
}
|
|
if (
|
|
b.type === 'tool_use' &&
|
|
b.name === SEND_MESSAGE_TOOL_NAME &&
|
|
typeof b.input === 'object' &&
|
|
b.input !== null &&
|
|
'to' in b.input &&
|
|
typeof b.input.to === 'string' &&
|
|
b.input.to !== '*' &&
|
|
b.input.to.toLowerCase() !== TEAM_LEAD_NAME.toLowerCase() &&
|
|
'message' in b.input &&
|
|
typeof b.input.message === 'string'
|
|
) {
|
|
const to = b.input.to as string
|
|
const summary =
|
|
'summary' in b.input && typeof b.input.summary === 'string'
|
|
? (b.input.summary as string)
|
|
: (b.input.message as string).slice(0, 80)
|
|
return `[to ${to}] ${summary}`
|
|
}
|
|
}
|
|
}
|
|
return undefined
|
|
}
|