teammateMailbox.ts 33 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187
  1. /**
  2. * Teammate Mailbox - File-based messaging system for agent swarms
  3. *
  4. * Each teammate has an inbox file at .claude/teams/{team_name}/inboxes/{agent_name}.json
  5. * Other teammates can write messages to it, and the recipient sees them as attachments.
  6. *
  7. * Note: Inboxes are keyed by agent name within a team.
  8. */
  9. import { mkdir, readFile, writeFile } from 'fs/promises'
  10. import { join } from 'path'
  11. import { z } from 'zod/v4'
  12. import { TEAMMATE_MESSAGE_TAG } from '../constants/xml.js'
  13. import { PermissionModeSchema } from '../entrypoints/sdk/coreSchemas.js'
  14. import { SEND_MESSAGE_TOOL_NAME } from '../tools/SendMessageTool/constants.js'
  15. import type { Message } from '../types/message.js'
  16. import { generateRequestId } from './agentId.js'
  17. import { count } from './array.js'
  18. import { logForDebugging } from './debug.js'
  19. import { getTeamsDir } from './envUtils.js'
  20. import { getErrnoCode } from './errors.js'
  21. import { lazySchema } from './lazySchema.js'
  22. import * as lockfile from './lockfile.js'
  23. import { logError } from './log.js'
  24. import { jsonParse, jsonStringify } from './slowOperations.js'
  25. import type { BackendType } from './swarm/backends/types.js'
  26. import { TEAM_LEAD_NAME } from './swarm/constants.js'
  27. import { sanitizePathComponent } from './tasks.js'
  28. import { getAgentName, getTeammateColor, getTeamName } from './teammate.js'
  29. // Lock options: retry with backoff so concurrent callers (multiple Claudes
  30. // in a swarm) wait for the lock instead of failing immediately. The sync
  31. // lockSync API blocked the event loop; the async API needs explicit retries
  32. // to achieve the same serialization semantics.
  33. const LOCK_OPTIONS = {
  34. retries: {
  35. retries: 10,
  36. minTimeout: 5,
  37. maxTimeout: 100,
  38. },
  39. }
  40. export type TeammateMessage = {
  41. from: string
  42. text: string
  43. timestamp: string
  44. read: boolean
  45. color?: string // Sender's assigned color (e.g., 'red', 'blue', 'green')
  46. summary?: string // 5-10 word summary shown as preview in the UI
  47. }
  48. /**
  49. * Get the path to a teammate's inbox file
  50. * Structure: ~/.claude/teams/{team_name}/inboxes/{agent_name}.json
  51. */
  52. export function getInboxPath(agentName: string, teamName?: string): string {
  53. const team = teamName || getTeamName() || 'default'
  54. const safeTeam = sanitizePathComponent(team)
  55. const safeAgentName = sanitizePathComponent(agentName)
  56. const inboxDir = join(getTeamsDir(), safeTeam, 'inboxes')
  57. const fullPath = join(inboxDir, `${safeAgentName}.json`)
  58. logForDebugging(
  59. `[TeammateMailbox] getInboxPath: agent=${agentName}, team=${team}, fullPath=${fullPath}`,
  60. )
  61. return fullPath
  62. }
  63. /**
  64. * Ensure the inbox directory exists for a team
  65. */
  66. async function ensureInboxDir(teamName?: string): Promise<void> {
  67. const team = teamName || getTeamName() || 'default'
  68. const safeTeam = sanitizePathComponent(team)
  69. const inboxDir = join(getTeamsDir(), safeTeam, 'inboxes')
  70. await mkdir(inboxDir, { recursive: true })
  71. logForDebugging(`[TeammateMailbox] Ensured inbox directory: ${inboxDir}`)
  72. }
  73. /**
  74. * Read all messages from a teammate's inbox
  75. * @param agentName - The agent name (not UUID) to read inbox for
  76. * @param teamName - Optional team name (defaults to CLAUDE_CODE_TEAM_NAME env var or 'default')
  77. */
  78. export async function readMailbox(
  79. agentName: string,
  80. teamName?: string,
  81. ): Promise<TeammateMessage[]> {
  82. const inboxPath = getInboxPath(agentName, teamName)
  83. logForDebugging(`[TeammateMailbox] readMailbox: path=${inboxPath}`)
  84. try {
  85. const content = await readFile(inboxPath, 'utf-8')
  86. const messages = jsonParse(content) as TeammateMessage[]
  87. logForDebugging(
  88. `[TeammateMailbox] readMailbox: read ${messages.length} message(s)`,
  89. )
  90. return messages
  91. } catch (error) {
  92. const code = getErrnoCode(error)
  93. if (code === 'ENOENT') {
  94. logForDebugging(`[TeammateMailbox] readMailbox: file does not exist`)
  95. return []
  96. }
  97. logForDebugging(`Failed to read inbox for ${agentName}: ${error}`)
  98. logError(error)
  99. return []
  100. }
  101. }
  102. /**
  103. * Read only unread messages from a teammate's inbox
  104. * @param agentName - The agent name (not UUID) to read inbox for
  105. * @param teamName - Optional team name
  106. */
  107. export async function readUnreadMessages(
  108. agentName: string,
  109. teamName?: string,
  110. ): Promise<TeammateMessage[]> {
  111. const messages = await readMailbox(agentName, teamName)
  112. const unread = messages.filter(m => !m.read)
  113. logForDebugging(
  114. `[TeammateMailbox] readUnreadMessages: ${unread.length} unread of ${messages.length} total`,
  115. )
  116. return unread
  117. }
  118. /**
  119. * Write a message to a teammate's inbox
  120. * Uses file locking to prevent race conditions when multiple agents write concurrently
  121. * @param recipientName - The recipient's agent name (not UUID)
  122. * @param message - The message to write
  123. * @param teamName - Optional team name
  124. */
  125. export async function writeToMailbox(
  126. recipientName: string,
  127. message: Omit<TeammateMessage, 'read'>,
  128. teamName?: string,
  129. ): Promise<void> {
  130. await ensureInboxDir(teamName)
  131. const inboxPath = getInboxPath(recipientName, teamName)
  132. const lockFilePath = `${inboxPath}.lock`
  133. logForDebugging(
  134. `[TeammateMailbox] writeToMailbox: recipient=${recipientName}, from=${message.from}, path=${inboxPath}`,
  135. )
  136. // Ensure the inbox file exists before locking (proper-lockfile requires the file to exist)
  137. try {
  138. await writeFile(inboxPath, '[]', { encoding: 'utf-8', flag: 'wx' })
  139. logForDebugging(`[TeammateMailbox] writeToMailbox: created new inbox file`)
  140. } catch (error) {
  141. const code = getErrnoCode(error)
  142. if (code !== 'EEXIST') {
  143. logForDebugging(
  144. `[TeammateMailbox] writeToMailbox: failed to create inbox file: ${error}`,
  145. )
  146. logError(error)
  147. return
  148. }
  149. }
  150. let release: (() => Promise<void>) | undefined
  151. try {
  152. release = await lockfile.lock(inboxPath, {
  153. lockfilePath: lockFilePath,
  154. ...LOCK_OPTIONS,
  155. })
  156. // Re-read messages after acquiring lock to get the latest state
  157. const messages = await readMailbox(recipientName, teamName)
  158. const newMessage: TeammateMessage = {
  159. ...message,
  160. read: false,
  161. }
  162. messages.push(newMessage)
  163. await writeFile(inboxPath, jsonStringify(messages, null, 2), 'utf-8')
  164. logForDebugging(
  165. `[TeammateMailbox] Wrote message to ${recipientName}'s inbox from ${message.from}`,
  166. )
  167. } catch (error) {
  168. logForDebugging(`Failed to write to inbox for ${recipientName}: ${error}`)
  169. logError(error)
  170. } finally {
  171. if (release) {
  172. await release()
  173. }
  174. }
  175. }
  176. /**
  177. * Mark a specific message in a teammate's inbox as read by index
  178. * Uses file locking to prevent race conditions
  179. * @param agentName - The agent name to mark message as read for
  180. * @param teamName - Optional team name
  181. * @param messageIndex - Index of the message to mark as read
  182. */
  183. export async function markMessageAsReadByIndex(
  184. agentName: string,
  185. teamName: string | undefined,
  186. messageIndex: number,
  187. ): Promise<void> {
  188. const inboxPath = getInboxPath(agentName, teamName)
  189. logForDebugging(
  190. `[TeammateMailbox] markMessageAsReadByIndex called: agentName=${agentName}, teamName=${teamName}, index=${messageIndex}, path=${inboxPath}`,
  191. )
  192. const lockFilePath = `${inboxPath}.lock`
  193. let release: (() => Promise<void>) | undefined
  194. try {
  195. logForDebugging(
  196. `[TeammateMailbox] markMessageAsReadByIndex: acquiring lock...`,
  197. )
  198. release = await lockfile.lock(inboxPath, {
  199. lockfilePath: lockFilePath,
  200. ...LOCK_OPTIONS,
  201. })
  202. logForDebugging(`[TeammateMailbox] markMessageAsReadByIndex: lock acquired`)
  203. // Re-read messages after acquiring lock to get the latest state
  204. const messages = await readMailbox(agentName, teamName)
  205. logForDebugging(
  206. `[TeammateMailbox] markMessageAsReadByIndex: read ${messages.length} messages after lock`,
  207. )
  208. if (messageIndex < 0 || messageIndex >= messages.length) {
  209. logForDebugging(
  210. `[TeammateMailbox] markMessageAsReadByIndex: index ${messageIndex} out of bounds (${messages.length} messages)`,
  211. )
  212. return
  213. }
  214. const message = messages[messageIndex]
  215. if (!message || message.read) {
  216. logForDebugging(
  217. `[TeammateMailbox] markMessageAsReadByIndex: message already read or missing`,
  218. )
  219. return
  220. }
  221. messages[messageIndex] = { ...message, read: true }
  222. await writeFile(inboxPath, jsonStringify(messages, null, 2), 'utf-8')
  223. logForDebugging(
  224. `[TeammateMailbox] markMessageAsReadByIndex: marked message at index ${messageIndex} as read`,
  225. )
  226. } catch (error) {
  227. const code = getErrnoCode(error)
  228. if (code === 'ENOENT') {
  229. logForDebugging(
  230. `[TeammateMailbox] markMessageAsReadByIndex: file does not exist at ${inboxPath}`,
  231. )
  232. return
  233. }
  234. logForDebugging(
  235. `[TeammateMailbox] markMessageAsReadByIndex FAILED for ${agentName}: ${error}`,
  236. )
  237. logError(error)
  238. } finally {
  239. if (release) {
  240. await release()
  241. logForDebugging(
  242. `[TeammateMailbox] markMessageAsReadByIndex: lock released`,
  243. )
  244. }
  245. }
  246. }
  247. /**
  248. * Mark all messages in a teammate's inbox as read
  249. * Uses file locking to prevent race conditions
  250. * @param agentName - The agent name to mark messages as read for
  251. * @param teamName - Optional team name
  252. */
  253. export async function markMessagesAsRead(
  254. agentName: string,
  255. teamName?: string,
  256. ): Promise<void> {
  257. const inboxPath = getInboxPath(agentName, teamName)
  258. logForDebugging(
  259. `[TeammateMailbox] markMessagesAsRead called: agentName=${agentName}, teamName=${teamName}, path=${inboxPath}`,
  260. )
  261. const lockFilePath = `${inboxPath}.lock`
  262. let release: (() => Promise<void>) | undefined
  263. try {
  264. logForDebugging(`[TeammateMailbox] markMessagesAsRead: acquiring lock...`)
  265. release = await lockfile.lock(inboxPath, {
  266. lockfilePath: lockFilePath,
  267. ...LOCK_OPTIONS,
  268. })
  269. logForDebugging(`[TeammateMailbox] markMessagesAsRead: lock acquired`)
  270. // Re-read messages after acquiring lock to get the latest state
  271. const messages = await readMailbox(agentName, teamName)
  272. logForDebugging(
  273. `[TeammateMailbox] markMessagesAsRead: read ${messages.length} messages after lock`,
  274. )
  275. if (messages.length === 0) {
  276. logForDebugging(
  277. `[TeammateMailbox] markMessagesAsRead: no messages to mark`,
  278. )
  279. return
  280. }
  281. const unreadCount = count(messages, m => !m.read)
  282. logForDebugging(
  283. `[TeammateMailbox] markMessagesAsRead: ${unreadCount} unread of ${messages.length} total`,
  284. )
  285. // messages comes from jsonParse — fresh, unshared objects safe to mutate
  286. for (const m of messages) m.read = true
  287. await writeFile(inboxPath, jsonStringify(messages, null, 2), 'utf-8')
  288. logForDebugging(
  289. `[TeammateMailbox] markMessagesAsRead: WROTE ${unreadCount} message(s) as read to ${inboxPath}`,
  290. )
  291. } catch (error) {
  292. const code = getErrnoCode(error)
  293. if (code === 'ENOENT') {
  294. logForDebugging(
  295. `[TeammateMailbox] markMessagesAsRead: file does not exist at ${inboxPath}`,
  296. )
  297. return
  298. }
  299. logForDebugging(
  300. `[TeammateMailbox] markMessagesAsRead FAILED for ${agentName}: ${error}`,
  301. )
  302. logError(error)
  303. } finally {
  304. if (release) {
  305. await release()
  306. logForDebugging(`[TeammateMailbox] markMessagesAsRead: lock released`)
  307. }
  308. }
  309. }
  310. /**
  311. * Clear a teammate's inbox (delete all messages)
  312. * @param agentName - The agent name to clear inbox for
  313. * @param teamName - Optional team name
  314. */
  315. export async function clearMailbox(
  316. agentName: string,
  317. teamName?: string,
  318. ): Promise<void> {
  319. const inboxPath = getInboxPath(agentName, teamName)
  320. try {
  321. // flag 'r+' throws ENOENT if the file doesn't exist, so we don't
  322. // accidentally create an inbox file that wasn't there.
  323. await writeFile(inboxPath, '[]', { encoding: 'utf-8', flag: 'r+' })
  324. logForDebugging(`[TeammateMailbox] Cleared inbox for ${agentName}`)
  325. } catch (error) {
  326. const code = getErrnoCode(error)
  327. if (code === 'ENOENT') {
  328. return
  329. }
  330. logForDebugging(`Failed to clear inbox for ${agentName}: ${error}`)
  331. logError(error)
  332. }
  333. }
  334. /**
  335. * Format teammate messages as XML for attachment display
  336. */
  337. export function formatTeammateMessages(
  338. messages: Array<{
  339. from: string
  340. text: string
  341. timestamp: string
  342. color?: string
  343. summary?: string
  344. }>,
  345. ): string {
  346. return messages
  347. .map(m => {
  348. const colorAttr = m.color ? ` color="${m.color}"` : ''
  349. const summaryAttr = m.summary ? ` summary="${m.summary}"` : ''
  350. return `<${TEAMMATE_MESSAGE_TAG} teammate_id="${m.from}"${colorAttr}${summaryAttr}>\n${m.text}\n</${TEAMMATE_MESSAGE_TAG}>`
  351. })
  352. .join('\n\n')
  353. }
  354. /**
  355. * Structured message sent when a teammate becomes idle (via Stop hook)
  356. */
  357. export type IdleNotificationMessage = {
  358. type: 'idle_notification'
  359. from: string
  360. timestamp: string
  361. /** Why the agent went idle */
  362. idleReason?: 'available' | 'interrupted' | 'failed'
  363. /** Brief summary of the last DM sent this turn (if any) */
  364. summary?: string
  365. completedTaskId?: string
  366. completedStatus?: 'resolved' | 'blocked' | 'failed'
  367. failureReason?: string
  368. }
  369. /**
  370. * Creates an idle notification message to send to the team leader
  371. */
  372. export function createIdleNotification(
  373. agentId: string,
  374. options?: {
  375. idleReason?: IdleNotificationMessage['idleReason']
  376. summary?: string
  377. completedTaskId?: string
  378. completedStatus?: 'resolved' | 'blocked' | 'failed'
  379. failureReason?: string
  380. },
  381. ): IdleNotificationMessage {
  382. return {
  383. type: 'idle_notification',
  384. from: agentId,
  385. timestamp: new Date().toISOString(),
  386. idleReason: options?.idleReason,
  387. summary: options?.summary,
  388. completedTaskId: options?.completedTaskId,
  389. completedStatus: options?.completedStatus,
  390. failureReason: options?.failureReason,
  391. }
  392. }
  393. /**
  394. * Checks if a message text contains an idle notification
  395. */
  396. export function isIdleNotification(
  397. messageText: string,
  398. ): IdleNotificationMessage | null {
  399. try {
  400. const parsed = jsonParse(messageText)
  401. if (parsed && parsed.type === 'idle_notification') {
  402. return parsed as IdleNotificationMessage
  403. }
  404. } catch {
  405. // Not JSON or not a valid idle notification
  406. }
  407. return null
  408. }
  409. /**
  410. * Permission request message sent from worker to leader via mailbox.
  411. * Field names align with SDK `can_use_tool` (snake_case).
  412. */
  413. export type PermissionRequestMessage = {
  414. type: 'permission_request'
  415. request_id: string
  416. agent_id: string
  417. tool_name: string
  418. tool_use_id: string
  419. description: string
  420. input: Record<string, unknown>
  421. permission_suggestions: unknown[]
  422. }
  423. /**
  424. * Permission response message sent from leader to worker via mailbox.
  425. * Shape mirrors SDK ControlResponseSchema / ControlErrorResponseSchema.
  426. */
  427. export type PermissionResponseMessage =
  428. | {
  429. type: 'permission_response'
  430. request_id: string
  431. subtype: 'success'
  432. response?: {
  433. updated_input?: Record<string, unknown>
  434. permission_updates?: unknown[]
  435. }
  436. }
  437. | {
  438. type: 'permission_response'
  439. request_id: string
  440. subtype: 'error'
  441. error: string
  442. }
  443. /**
  444. * Creates a permission request message to send to the team leader
  445. */
  446. export function createPermissionRequestMessage(params: {
  447. request_id: string
  448. agent_id: string
  449. tool_name: string
  450. tool_use_id: string
  451. description: string
  452. input: Record<string, unknown>
  453. permission_suggestions?: unknown[]
  454. }): PermissionRequestMessage {
  455. return {
  456. type: 'permission_request',
  457. request_id: params.request_id,
  458. agent_id: params.agent_id,
  459. tool_name: params.tool_name,
  460. tool_use_id: params.tool_use_id,
  461. description: params.description,
  462. input: params.input,
  463. permission_suggestions: params.permission_suggestions || [],
  464. }
  465. }
  466. /**
  467. * Creates a permission response message to send back to a worker
  468. */
  469. export function createPermissionResponseMessage(params: {
  470. request_id: string
  471. subtype: 'success' | 'error'
  472. error?: string
  473. updated_input?: Record<string, unknown>
  474. permission_updates?: unknown[]
  475. }): PermissionResponseMessage {
  476. if (params.subtype === 'error') {
  477. return {
  478. type: 'permission_response',
  479. request_id: params.request_id,
  480. subtype: 'error',
  481. error: params.error || 'Permission denied',
  482. }
  483. }
  484. return {
  485. type: 'permission_response',
  486. request_id: params.request_id,
  487. subtype: 'success',
  488. response: {
  489. updated_input: params.updated_input,
  490. permission_updates: params.permission_updates,
  491. },
  492. }
  493. }
  494. /**
  495. * Checks if a message text contains a permission request
  496. */
  497. export function isPermissionRequest(
  498. messageText: string,
  499. ): PermissionRequestMessage | null {
  500. try {
  501. const parsed = jsonParse(messageText)
  502. if (parsed && parsed.type === 'permission_request') {
  503. return parsed as PermissionRequestMessage
  504. }
  505. } catch {
  506. // Not JSON or not a valid permission request
  507. }
  508. return null
  509. }
  510. /**
  511. * Checks if a message text contains a permission response
  512. */
  513. export function isPermissionResponse(
  514. messageText: string,
  515. ): PermissionResponseMessage | null {
  516. try {
  517. const parsed = jsonParse(messageText)
  518. if (parsed && parsed.type === 'permission_response') {
  519. return parsed as PermissionResponseMessage
  520. }
  521. } catch {
  522. // Not JSON or not a valid permission response
  523. }
  524. return null
  525. }
  526. /**
  527. * Sandbox permission request message sent from worker to leader via mailbox
  528. * This is triggered when sandbox runtime detects a network access to a non-allowed host
  529. */
  530. export type SandboxPermissionRequestMessage = {
  531. type: 'sandbox_permission_request'
  532. /** Unique identifier for this request */
  533. requestId: string
  534. /** Worker's CLAUDE_CODE_AGENT_ID */
  535. workerId: string
  536. /** Worker's CLAUDE_CODE_AGENT_NAME */
  537. workerName: string
  538. /** Worker's CLAUDE_CODE_AGENT_COLOR */
  539. workerColor?: string
  540. /** The host pattern requesting network access */
  541. hostPattern: {
  542. host: string
  543. }
  544. /** Timestamp when request was created */
  545. createdAt: number
  546. }
  547. /**
  548. * Sandbox permission response message sent from leader to worker via mailbox
  549. */
  550. export type SandboxPermissionResponseMessage = {
  551. type: 'sandbox_permission_response'
  552. /** ID of the request this responds to */
  553. requestId: string
  554. /** The host that was approved/denied */
  555. host: string
  556. /** Whether the connection is allowed */
  557. allow: boolean
  558. /** Timestamp when response was created */
  559. timestamp: string
  560. }
  561. /**
  562. * Creates a sandbox permission request message to send to the team leader
  563. */
  564. export function createSandboxPermissionRequestMessage(params: {
  565. requestId: string
  566. workerId: string
  567. workerName: string
  568. workerColor?: string
  569. host: string
  570. }): SandboxPermissionRequestMessage {
  571. return {
  572. type: 'sandbox_permission_request',
  573. requestId: params.requestId,
  574. workerId: params.workerId,
  575. workerName: params.workerName,
  576. workerColor: params.workerColor,
  577. hostPattern: { host: params.host },
  578. createdAt: Date.now(),
  579. }
  580. }
  581. /**
  582. * Creates a sandbox permission response message to send back to a worker
  583. */
  584. export function createSandboxPermissionResponseMessage(params: {
  585. requestId: string
  586. host: string
  587. allow: boolean
  588. }): SandboxPermissionResponseMessage {
  589. return {
  590. type: 'sandbox_permission_response',
  591. requestId: params.requestId,
  592. host: params.host,
  593. allow: params.allow,
  594. timestamp: new Date().toISOString(),
  595. }
  596. }
  597. /**
  598. * Checks if a message text contains a sandbox permission request
  599. */
  600. export function isSandboxPermissionRequest(
  601. messageText: string,
  602. ): SandboxPermissionRequestMessage | null {
  603. try {
  604. const parsed = jsonParse(messageText)
  605. if (parsed && parsed.type === 'sandbox_permission_request') {
  606. return parsed as SandboxPermissionRequestMessage
  607. }
  608. } catch {
  609. // Not JSON or not a valid sandbox permission request
  610. }
  611. return null
  612. }
  613. /**
  614. * Checks if a message text contains a sandbox permission response
  615. */
  616. export function isSandboxPermissionResponse(
  617. messageText: string,
  618. ): SandboxPermissionResponseMessage | null {
  619. try {
  620. const parsed = jsonParse(messageText)
  621. if (parsed && parsed.type === 'sandbox_permission_response') {
  622. return parsed as SandboxPermissionResponseMessage
  623. }
  624. } catch {
  625. // Not JSON or not a valid sandbox permission response
  626. }
  627. return null
  628. }
  629. /**
  630. * Message sent when a teammate requests plan approval from the team leader
  631. */
  632. export const PlanApprovalRequestMessageSchema = lazySchema(() =>
  633. z.object({
  634. type: z.literal('plan_approval_request'),
  635. from: z.string(),
  636. timestamp: z.string(),
  637. planFilePath: z.string(),
  638. planContent: z.string(),
  639. requestId: z.string(),
  640. }),
  641. )
  642. export type PlanApprovalRequestMessage = z.infer<
  643. ReturnType<typeof PlanApprovalRequestMessageSchema>
  644. >
  645. /**
  646. * Message sent by the team leader in response to a plan approval request
  647. */
  648. export const PlanApprovalResponseMessageSchema = lazySchema(() =>
  649. z.object({
  650. type: z.literal('plan_approval_response'),
  651. requestId: z.string(),
  652. approved: z.boolean(),
  653. feedback: z.string().optional(),
  654. timestamp: z.string(),
  655. permissionMode: PermissionModeSchema().optional(),
  656. }),
  657. )
  658. export type PlanApprovalResponseMessage = z.infer<
  659. ReturnType<typeof PlanApprovalResponseMessageSchema>
  660. >
  661. /**
  662. * Shutdown request message sent from leader to teammate via mailbox
  663. */
  664. export const ShutdownRequestMessageSchema = lazySchema(() =>
  665. z.object({
  666. type: z.literal('shutdown_request'),
  667. requestId: z.string(),
  668. from: z.string(),
  669. reason: z.string().optional(),
  670. timestamp: z.string(),
  671. }),
  672. )
  673. export type ShutdownRequestMessage = z.infer<
  674. ReturnType<typeof ShutdownRequestMessageSchema>
  675. >
  676. /**
  677. * Shutdown approved message sent from teammate to leader via mailbox
  678. */
  679. export const ShutdownApprovedMessageSchema = lazySchema(() =>
  680. z.object({
  681. type: z.literal('shutdown_approved'),
  682. requestId: z.string(),
  683. from: z.string(),
  684. timestamp: z.string(),
  685. paneId: z.string().optional(),
  686. backendType: z.string().optional(),
  687. }),
  688. )
  689. export type ShutdownApprovedMessage = z.infer<
  690. ReturnType<typeof ShutdownApprovedMessageSchema>
  691. >
  692. /**
  693. * Shutdown rejected message sent from teammate to leader via mailbox
  694. */
  695. export const ShutdownRejectedMessageSchema = lazySchema(() =>
  696. z.object({
  697. type: z.literal('shutdown_rejected'),
  698. requestId: z.string(),
  699. from: z.string(),
  700. reason: z.string(),
  701. timestamp: z.string(),
  702. }),
  703. )
  704. export type ShutdownRejectedMessage = z.infer<
  705. ReturnType<typeof ShutdownRejectedMessageSchema>
  706. >
  707. /**
  708. * Creates a shutdown request message to send to a teammate
  709. */
  710. export function createShutdownRequestMessage(params: {
  711. requestId: string
  712. from: string
  713. reason?: string
  714. }): ShutdownRequestMessage {
  715. return {
  716. type: 'shutdown_request',
  717. requestId: params.requestId,
  718. from: params.from,
  719. reason: params.reason,
  720. timestamp: new Date().toISOString(),
  721. }
  722. }
  723. /**
  724. * Creates a shutdown approved message to send to the team leader
  725. */
  726. export function createShutdownApprovedMessage(params: {
  727. requestId: string
  728. from: string
  729. paneId?: string
  730. backendType?: BackendType
  731. }): ShutdownApprovedMessage {
  732. return {
  733. type: 'shutdown_approved',
  734. requestId: params.requestId,
  735. from: params.from,
  736. timestamp: new Date().toISOString(),
  737. paneId: params.paneId,
  738. backendType: params.backendType,
  739. }
  740. }
  741. /**
  742. * Creates a shutdown rejected message to send to the team leader
  743. */
  744. export function createShutdownRejectedMessage(params: {
  745. requestId: string
  746. from: string
  747. reason: string
  748. }): ShutdownRejectedMessage {
  749. return {
  750. type: 'shutdown_rejected',
  751. requestId: params.requestId,
  752. from: params.from,
  753. reason: params.reason,
  754. timestamp: new Date().toISOString(),
  755. }
  756. }
  757. /**
  758. * Sends a shutdown request to a teammate's mailbox.
  759. * This is the core logic extracted for reuse by both the tool and UI components.
  760. *
  761. * @param targetName - Name of the teammate to send shutdown request to
  762. * @param teamName - Optional team name (defaults to CLAUDE_CODE_TEAM_NAME env var)
  763. * @param reason - Optional reason for the shutdown request
  764. * @returns The request ID and target name
  765. */
  766. export async function sendShutdownRequestToMailbox(
  767. targetName: string,
  768. teamName?: string,
  769. reason?: string,
  770. ): Promise<{ requestId: string; target: string }> {
  771. const resolvedTeamName = teamName || getTeamName()
  772. // Get sender name (supports in-process teammates via AsyncLocalStorage)
  773. const senderName = getAgentName() || TEAM_LEAD_NAME
  774. // Generate a deterministic request ID for this shutdown request
  775. const requestId = generateRequestId('shutdown', targetName)
  776. // Create and send the shutdown request message
  777. const shutdownMessage = createShutdownRequestMessage({
  778. requestId,
  779. from: senderName,
  780. reason,
  781. })
  782. await writeToMailbox(
  783. targetName,
  784. {
  785. from: senderName,
  786. text: jsonStringify(shutdownMessage),
  787. timestamp: new Date().toISOString(),
  788. color: getTeammateColor(),
  789. },
  790. resolvedTeamName,
  791. )
  792. return { requestId, target: targetName }
  793. }
  794. /**
  795. * Checks if a message text contains a shutdown request
  796. */
  797. export function isShutdownRequest(
  798. messageText: string,
  799. ): ShutdownRequestMessage | null {
  800. try {
  801. const result = ShutdownRequestMessageSchema().safeParse(
  802. jsonParse(messageText),
  803. )
  804. if (result.success) return result.data
  805. } catch {
  806. // Not JSON
  807. }
  808. return null
  809. }
  810. /**
  811. * Checks if a message text contains a plan approval request
  812. */
  813. export function isPlanApprovalRequest(
  814. messageText: string,
  815. ): PlanApprovalRequestMessage | null {
  816. try {
  817. const result = PlanApprovalRequestMessageSchema().safeParse(
  818. jsonParse(messageText),
  819. )
  820. if (result.success) return result.data
  821. } catch {
  822. // Not JSON
  823. }
  824. return null
  825. }
  826. /**
  827. * Checks if a message text contains a shutdown approved message
  828. */
  829. export function isShutdownApproved(
  830. messageText: string,
  831. ): ShutdownApprovedMessage | null {
  832. try {
  833. const result = ShutdownApprovedMessageSchema().safeParse(
  834. jsonParse(messageText),
  835. )
  836. if (result.success) return result.data
  837. } catch {
  838. // Not JSON
  839. }
  840. return null
  841. }
  842. /**
  843. * Checks if a message text contains a shutdown rejected message
  844. */
  845. export function isShutdownRejected(
  846. messageText: string,
  847. ): ShutdownRejectedMessage | null {
  848. try {
  849. const result = ShutdownRejectedMessageSchema().safeParse(
  850. jsonParse(messageText),
  851. )
  852. if (result.success) return result.data
  853. } catch {
  854. // Not JSON
  855. }
  856. return null
  857. }
  858. /**
  859. * Checks if a message text contains a plan approval response
  860. */
  861. export function isPlanApprovalResponse(
  862. messageText: string,
  863. ): PlanApprovalResponseMessage | null {
  864. try {
  865. const result = PlanApprovalResponseMessageSchema().safeParse(
  866. jsonParse(messageText),
  867. )
  868. if (result.success) return result.data
  869. } catch {
  870. // Not JSON
  871. }
  872. return null
  873. }
  874. /**
  875. * Task assignment message sent when a task is assigned to a teammate
  876. */
  877. export type TaskAssignmentMessage = {
  878. type: 'task_assignment'
  879. taskId: string
  880. subject: string
  881. description: string
  882. assignedBy: string
  883. timestamp: string
  884. }
  885. /**
  886. * Checks if a message text contains a task assignment
  887. */
  888. export function isTaskAssignment(
  889. messageText: string,
  890. ): TaskAssignmentMessage | null {
  891. try {
  892. const parsed = jsonParse(messageText)
  893. if (parsed && parsed.type === 'task_assignment') {
  894. return parsed as TaskAssignmentMessage
  895. }
  896. } catch {
  897. // Not JSON or not a valid task assignment
  898. }
  899. return null
  900. }
  901. /**
  902. * Team permission update message sent from leader to teammates via mailbox
  903. * Broadcasts a permission update that applies to all teammates
  904. */
  905. export type TeamPermissionUpdateMessage = {
  906. type: 'team_permission_update'
  907. /** The permission update to apply */
  908. permissionUpdate: {
  909. type: 'addRules'
  910. rules: Array<{ toolName: string; ruleContent?: string }>
  911. behavior: 'allow' | 'deny' | 'ask'
  912. destination: 'session'
  913. }
  914. /** The directory path that was allowed */
  915. directoryPath: string
  916. /** The tool name this applies to */
  917. toolName: string
  918. }
  919. /**
  920. * Checks if a message text contains a team permission update
  921. */
  922. export function isTeamPermissionUpdate(
  923. messageText: string,
  924. ): TeamPermissionUpdateMessage | null {
  925. try {
  926. const parsed = jsonParse(messageText)
  927. if (parsed && parsed.type === 'team_permission_update') {
  928. return parsed as TeamPermissionUpdateMessage
  929. }
  930. } catch {
  931. // Not JSON or not a valid team permission update
  932. }
  933. return null
  934. }
  935. /**
  936. * Mode set request message sent from leader to teammate via mailbox
  937. * Uses SDK PermissionModeSchema for validated mode values
  938. */
  939. export const ModeSetRequestMessageSchema = lazySchema(() =>
  940. z.object({
  941. type: z.literal('mode_set_request'),
  942. mode: PermissionModeSchema(),
  943. from: z.string(),
  944. }),
  945. )
  946. export type ModeSetRequestMessage = z.infer<
  947. ReturnType<typeof ModeSetRequestMessageSchema>
  948. >
  949. /**
  950. * Creates a mode set request message to send to a teammate
  951. */
  952. export function createModeSetRequestMessage(params: {
  953. mode: string
  954. from: string
  955. }): ModeSetRequestMessage {
  956. return {
  957. type: 'mode_set_request',
  958. mode: params.mode as ModeSetRequestMessage['mode'],
  959. from: params.from,
  960. }
  961. }
  962. /**
  963. * Checks if a message text contains a mode set request
  964. */
  965. export function isModeSetRequest(
  966. messageText: string,
  967. ): ModeSetRequestMessage | null {
  968. try {
  969. const parsed = ModeSetRequestMessageSchema().safeParse(
  970. jsonParse(messageText),
  971. )
  972. if (parsed.success) {
  973. return parsed.data
  974. }
  975. } catch {
  976. // Not JSON or not a valid mode set request
  977. }
  978. return null
  979. }
  980. /**
  981. * Checks if a message text is a structured protocol message that should be
  982. * routed by useInboxPoller rather than consumed as raw LLM context.
  983. *
  984. * These message types have specific handlers in useInboxPoller that route them
  985. * to the correct queues (workerPermissions, workerSandboxPermissions, etc.).
  986. * If getTeammateMailboxAttachments consumes them first, they get bundled as
  987. * raw text in attachments and never reach their intended handlers.
  988. */
  989. export function isStructuredProtocolMessage(messageText: string): boolean {
  990. try {
  991. const parsed = jsonParse(messageText)
  992. if (!parsed || typeof parsed !== 'object' || !('type' in parsed)) {
  993. return false
  994. }
  995. const type = (parsed as { type: unknown }).type
  996. return (
  997. type === 'permission_request' ||
  998. type === 'permission_response' ||
  999. type === 'sandbox_permission_request' ||
  1000. type === 'sandbox_permission_response' ||
  1001. type === 'shutdown_request' ||
  1002. type === 'shutdown_approved' ||
  1003. type === 'team_permission_update' ||
  1004. type === 'mode_set_request' ||
  1005. type === 'plan_approval_request' ||
  1006. type === 'plan_approval_response'
  1007. )
  1008. } catch {
  1009. return false
  1010. }
  1011. }
  1012. /**
  1013. * Marks only messages matching a predicate as read, leaving others unread.
  1014. * Uses the same file-locking mechanism as markMessagesAsRead.
  1015. */
  1016. export async function markMessagesAsReadByPredicate(
  1017. agentName: string,
  1018. predicate: (msg: TeammateMessage) => boolean,
  1019. teamName?: string,
  1020. ): Promise<void> {
  1021. const inboxPath = getInboxPath(agentName, teamName)
  1022. const lockFilePath = `${inboxPath}.lock`
  1023. let release: (() => Promise<void>) | undefined
  1024. try {
  1025. release = await lockfile.lock(inboxPath, {
  1026. lockfilePath: lockFilePath,
  1027. ...LOCK_OPTIONS,
  1028. })
  1029. const messages = await readMailbox(agentName, teamName)
  1030. if (messages.length === 0) {
  1031. return
  1032. }
  1033. const updatedMessages = messages.map(m =>
  1034. !m.read && predicate(m) ? { ...m, read: true } : m,
  1035. )
  1036. await writeFile(inboxPath, jsonStringify(updatedMessages, null, 2), 'utf-8')
  1037. } catch (error) {
  1038. const code = getErrnoCode(error)
  1039. if (code === 'ENOENT') {
  1040. return
  1041. }
  1042. logError(error)
  1043. } finally {
  1044. if (release) {
  1045. try {
  1046. await release()
  1047. } catch {
  1048. // Lock may have already been released
  1049. }
  1050. }
  1051. }
  1052. }
  1053. /**
  1054. * Extracts a "[to {name}] {summary}" string from the last assistant message
  1055. * if it ended with a SendMessage tool_use targeting a peer (not the team lead).
  1056. * Returns undefined when the turn didn't end with a peer DM.
  1057. */
  1058. export function getLastPeerDmSummary(messages: Message[]): string | undefined {
  1059. for (let i = messages.length - 1; i >= 0; i--) {
  1060. const msg = messages[i]
  1061. if (!msg) continue
  1062. // Stop at wake-up boundary: a user prompt (string content), not tool results (array content)
  1063. if (msg.type === 'user' && typeof msg.message.content === 'string') {
  1064. break
  1065. }
  1066. if (msg.type !== 'assistant') continue
  1067. const content = msg.message?.content
  1068. if (!Array.isArray(content)) continue
  1069. for (const block of content) {
  1070. if (typeof block === 'string') continue
  1071. const b = block as unknown as { type: string; name?: string; input?: Record<string, unknown>; [key: string]: unknown }
  1072. if (
  1073. b.type === 'tool_use' &&
  1074. b.name === SEND_MESSAGE_TOOL_NAME &&
  1075. typeof b.input === 'object' &&
  1076. b.input !== null &&
  1077. 'to' in b.input &&
  1078. typeof b.input.to === 'string' &&
  1079. b.input.to !== '*' &&
  1080. b.input.to.toLowerCase() !== TEAM_LEAD_NAME.toLowerCase() &&
  1081. 'message' in b.input &&
  1082. typeof b.input.message === 'string'
  1083. ) {
  1084. const to = b.input.to as string
  1085. const summary =
  1086. 'summary' in b.input && typeof b.input.summary === 'string'
  1087. ? b.input.summary as string
  1088. : (b.input.message as string).slice(0, 80)
  1089. return `[to ${to}] ${summary}`
  1090. }
  1091. }
  1092. }
  1093. return undefined
  1094. }