cross-agent-teams-mcp 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +296 -0
  3. package/README.zh-CN.md +306 -0
  4. package/dist/channel-cli.d.ts +18 -0
  5. package/dist/channel-cli.js +358 -0
  6. package/dist/channel-cli.js.map +1 -0
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.js +4585 -0
  9. package/dist/cli.js.map +1 -0
  10. package/package.json +62 -0
  11. package/src/channel/auto-daemon.ts +130 -0
  12. package/src/channel/daemon-client.ts +155 -0
  13. package/src/channel/proxy.ts +28 -0
  14. package/src/channel-cli.ts +122 -0
  15. package/src/cli.ts +136 -0
  16. package/src/daemon/auth.ts +17 -0
  17. package/src/daemon/channel-wake-fanout.ts +39 -0
  18. package/src/daemon/channel-wake-send.ts +38 -0
  19. package/src/daemon/cleanup.ts +38 -0
  20. package/src/daemon/errors.ts +18 -0
  21. package/src/daemon/pid.ts +33 -0
  22. package/src/daemon/port.ts +16 -0
  23. package/src/daemon/runtime-identity.ts +238 -0
  24. package/src/daemon/server.ts +64 -0
  25. package/src/daemon/shutdown.ts +12 -0
  26. package/src/daemon/sse-fanout.ts +96 -0
  27. package/src/daemon/tmux-cli.ts +61 -0
  28. package/src/daemon/tmux-pane-detect.ts +276 -0
  29. package/src/lib/client-kind.ts +1 -0
  30. package/src/lib/default-team.ts +18 -0
  31. package/src/lib/delivery-spec.ts +172 -0
  32. package/src/lib/schema-diff.ts +79 -0
  33. package/src/mcp/agent-public-row.ts +52 -0
  34. package/src/mcp/auto-bind-channel.ts +106 -0
  35. package/src/mcp/auto-bind-codex-pane.ts +170 -0
  36. package/src/mcp/auto-poke-fanout.ts +129 -0
  37. package/src/mcp/bind-channel.ts +39 -0
  38. package/src/mcp/bind-runtime-identity.ts +43 -0
  39. package/src/mcp/broadcast-to-role.ts +127 -0
  40. package/src/mcp/broadcast.ts +115 -0
  41. package/src/mcp/codex-appserver-dispatch.ts +169 -0
  42. package/src/mcp/codex-appserver-rpc.ts +227 -0
  43. package/src/mcp/codex-pane-pre-register-repo.ts +57 -0
  44. package/src/mcp/delivery-status.ts +114 -0
  45. package/src/mcp/diff-contracts.ts +25 -0
  46. package/src/mcp/echo.ts +8 -0
  47. package/src/mcp/fanout-with-retry.ts +56 -0
  48. package/src/mcp/get-contract.ts +24 -0
  49. package/src/mcp/get-inbox.ts +57 -0
  50. package/src/mcp/identity.ts +8 -0
  51. package/src/mcp/pending-contract-events.ts +36 -0
  52. package/src/mcp/poke-guard.ts +32 -0
  53. package/src/mcp/poke-retry.ts +159 -0
  54. package/src/mcp/poke.ts +190 -0
  55. package/src/mcp/pre-register-codex-pane.ts +65 -0
  56. package/src/mcp/register-agent.ts +84 -0
  57. package/src/mcp/register-codex-self.ts +276 -0
  58. package/src/mcp/register-contract.ts +60 -0
  59. package/src/mcp/send-message.ts +159 -0
  60. package/src/mcp/subscribe-channel-wake.ts +31 -0
  61. package/src/mcp/subscribe-contract.ts +24 -0
  62. package/src/mcp/task-add.ts +37 -0
  63. package/src/mcp/task-claim.ts +54 -0
  64. package/src/mcp/task-complete.ts +36 -0
  65. package/src/mcp/task-list.ts +33 -0
  66. package/src/mcp/tools.ts +1240 -0
  67. package/src/mcp/transport-dispatch.ts +171 -0
  68. package/src/mcp/transport.ts +204 -0
  69. package/src/mcp/unregister-self.ts +46 -0
  70. package/src/storage/agents-repo.ts +328 -0
  71. package/src/storage/db.ts +13 -0
  72. package/src/storage/events-outbox.ts +44 -0
  73. package/src/storage/schema.ts +180 -0
@@ -0,0 +1,1240 @@
1
+ import type Database from 'better-sqlite3'
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
3
+ import { z } from 'zod'
4
+ import { AgentsRepo } from '../storage/agents-repo.js'
5
+ import { EventsOutbox } from '../storage/events-outbox.js'
6
+ import { RegisterAgentService } from './register-agent.js'
7
+ import { SendMessageService } from './send-message.js'
8
+ import { BroadcastService } from './broadcast.js'
9
+ import { BroadcastToRoleService } from './broadcast-to-role.js'
10
+ import { GetInboxService } from './get-inbox.js'
11
+ import { TaskAddService } from './task-add.js'
12
+ import { TaskClaimService } from './task-claim.js'
13
+ import { TaskCompleteService } from './task-complete.js'
14
+ import { TaskListService } from './task-list.js'
15
+ import { RegisterContractService } from './register-contract.js'
16
+ import { SubscribeContractService } from './subscribe-contract.js'
17
+ import { GetContractService } from './get-contract.js'
18
+ import { DiffContractsService } from './diff-contracts.js'
19
+ import { PendingContractEventsService } from './pending-contract-events.js'
20
+ import { GetDeliveryStatusService } from './delivery-status.js'
21
+ import { poke } from './poke.js'
22
+ import { wrapStorage } from '../daemon/errors.js'
23
+ import type { SseFanout } from '../daemon/sse-fanout.js'
24
+ import type { ChannelWakeFanout } from '../daemon/channel-wake-fanout.js'
25
+ import { SubscribeChannelWakeService } from './subscribe-channel-wake.js'
26
+ import { BindChannelService } from './bind-channel.js'
27
+ import { AutoBindChannelService } from './auto-bind-channel.js'
28
+ import { BindRuntimeIdentityService } from './bind-runtime-identity.js'
29
+ import { RegisterCodexSelfService } from './register-codex-self.js'
30
+ import { UnregisterSelfService } from './unregister-self.js'
31
+ import { toPublicAgentRow } from './agent-public-row.js'
32
+ import { detectTmuxPane } from '../daemon/tmux-pane-detect.js'
33
+ import { bindRuntimeIdentity } from '../daemon/runtime-identity.js'
34
+ import type { DetectAgentKind } from '../daemon/tmux-pane-detect.js'
35
+ import type { ClientKind } from '../lib/client-kind.js'
36
+ import { CodexPanePreRegRepo } from './codex-pane-pre-register-repo.js'
37
+ import {
38
+ PreRegisterCodexPaneService,
39
+ preRegisterCodexPaneInputSchema,
40
+ } from './pre-register-codex-pane.js'
41
+ import { autoBindCodexPane } from './auto-bind-codex-pane.js'
42
+
43
+ export interface AgentIdHolder { current: string | undefined }
44
+
45
+ type TextContent = { content: Array<{ type: 'text'; text: string }> }
46
+
47
+ function toText(value: unknown): TextContent {
48
+ return { content: [{ type: 'text', text: JSON.stringify(value) }] }
49
+ }
50
+
51
+ const deliverySchema = z.object({
52
+ kind: z.string(),
53
+ }).passthrough()
54
+
55
+ const clientSchema = z.enum(['codex', 'claude-code', 'opencode', 'custom'])
56
+
57
+ const detectTmuxPaneSchema = z.object({
58
+ agent: z.enum(['codex', 'claude-code', 'opencode', 'custom']),
59
+ cwd: z.string().optional(),
60
+ tty: z.string().optional(),
61
+ title_contains: z.string().optional(),
62
+ process_pattern: z.string().optional(),
63
+ })
64
+
65
+ const detectTmuxPaneArgsSchema = detectTmuxPaneSchema.superRefine((value, ctx) => {
66
+ if (value.agent === 'custom' && (!value.process_pattern || value.process_pattern.trim().length === 0)) {
67
+ ctx.addIssue({
68
+ code: z.ZodIssueCode.custom,
69
+ path: ['process_pattern'],
70
+ message: 'process_pattern is required when agent=custom',
71
+ })
72
+ }
73
+ })
74
+
75
+ const bindRuntimeIdentitySchema = z.object({
76
+ agent: z.enum(['codex', 'claude-code', 'opencode', 'custom']),
77
+ ui_pid: z.number().int().positive().optional(),
78
+ ui_tty: z.string().optional(),
79
+ tmux_pane_id: z.string().min(1).optional(),
80
+ process_pattern: z.string().optional(),
81
+ })
82
+
83
+ const bindRuntimeIdentityArgsSchema = bindRuntimeIdentitySchema.superRefine((value, ctx) => {
84
+ if (value.agent === 'custom' && (!value.process_pattern || value.process_pattern.trim().length === 0)) {
85
+ ctx.addIssue({
86
+ code: z.ZodIssueCode.custom,
87
+ path: ['process_pattern'],
88
+ message: 'process_pattern is required when agent=custom',
89
+ })
90
+ }
91
+ const hasPid = value.ui_pid !== undefined
92
+ const hasTtyPair =
93
+ value.ui_tty !== undefined &&
94
+ value.ui_tty.trim().length > 0 &&
95
+ value.tmux_pane_id !== undefined &&
96
+ value.tmux_pane_id.trim().length > 0
97
+ if (!hasPid && !hasTtyPair) {
98
+ ctx.addIssue({
99
+ code: z.ZodIssueCode.custom,
100
+ message: 'provide ui_pid, or ui_tty together with tmux_pane_id',
101
+ })
102
+ }
103
+ })
104
+
105
+ const SEND_MESSAGE_DESC = [
106
+ 'Private 1→1 message to another agent by name. By default auto-poke=true with quiet-guard (auto_poke:false opts out), and need_reply=true.',
107
+ 'Set need_reply:false for FYI/no-response-needed messages; recipients see need_reply in get_inbox.',
108
+ 'to_agent_name is the target\'s `name` within its team; this is the preferred addressing form. For UUID-based sends use send_message_by_id.',
109
+ 'For multi-recipient use broadcast (same-team) or broadcast_to_role (same-team, by role).',
110
+ '除非用户明确指定 to_team, 不要跨 team 沟通 (explicitly set to_team only when user asks).',
111
+ 'Reports poked, poke_skip_reasons (no_pane, guard_failed, tmux_unavailable, self); on guard_failed daemon retries at 30s/180s/600s (retry_scheduled, retry_delays_s); stops early on poked.',
112
+ 'Auto-poke injects only a SHORT wake-up hint (新邮件 from <sender>, 请调 get_inbox 查看), NOT the body — read bodies via get_inbox.',
113
+ 'Delivery is NOT filtered by online/idle (unlike broadcast\'s 5 min idle skip) — offline targets still receive the mailbox row.'
114
+ ].join(' ')
115
+
116
+ const SEND_MESSAGE_BY_ID_DESC = [
117
+ 'Private 1→1 message to another agent by agent_id (UUID). Use this when you already hold the target\'s agent_id; prefer send_message (by name) otherwise.',
118
+ 'Same-team only: the recipient must belong to the caller\'s team. For cross-team sends use send_message with to_team.',
119
+ 'By default auto-poke=true with quiet-guard (auto_poke:false opts out), and need_reply=true. Set need_reply:false for FYI/no-response-needed messages.',
120
+ 'Reports poked, poke_skip_reasons (no_pane, guard_failed, tmux_unavailable, self); on guard_failed daemon retries at 30s/180s/600s (retry_scheduled, retry_delays_s); stops early on poked.',
121
+ 'Auto-poke injects only a SHORT wake-up hint (新邮件 from <sender>, 请调 get_inbox 查看), NOT the body — read bodies via get_inbox.',
122
+ 'Delivery is NOT filtered by online/idle — offline targets still receive the mailbox row.'
123
+ ].join(' ')
124
+
125
+ const BROADCAST_DESC = [
126
+ 'Same-team broadcast to every other agent in the caller team.',
127
+ 'Auto-poke default true (quiet-guard + 30s/180s/600s retry; reports poked, poke_skip_reasons, retry_scheduled, retry_delays_s). auto_poke:false opts out.',
128
+ 'For role filter use broadcast_to_role. For cross-team 1→1 use send_message({to_team}).',
129
+ 'Auto-poke injects only a SHORT wake-up hint (新邮件 from <sender>, 请调 get_inbox 查看) — never the body. Read via get_inbox.',
130
+ 'Skips agents idle > 5 min (offline).'
131
+ ].join(' ')
132
+
133
+ const BROADCAST_TO_ROLE_DESC = [
134
+ 'Same-team broadcast filtered by role. Strictly same-team — no cross-team variant.',
135
+ 'For cross-team private 1→1 use send_message({to_team}).',
136
+ 'Auto-poke default true with quiet-guard + 30s/180s/600s retry (auto_poke:false opts out); injects only a SHORT wake-up hint, not the message body. Recipients read via get_inbox.',
137
+ 'Returns unknown_recipient when no same-team agent matches to_role.'
138
+ ].join(' ')
139
+
140
+ function suppressTmuxHint(
141
+ args: { delivery?: { kind?: string } }
142
+ ): boolean {
143
+ return args.delivery?.kind !== undefined && args.delivery.kind !== 'none'
144
+ }
145
+
146
+ function defaultClaudeSelfModel(
147
+ clientInfo: SessionClientInfo | undefined
148
+ ): string {
149
+ const raw = `${clientInfo?.name ?? ''} ${clientInfo?.version ?? ''}`.trim()
150
+ if (/claude/i.test(raw)) return raw
151
+ return 'claude-code'
152
+ }
153
+
154
+ export function buildAutoPokeHint(
155
+ row: { name?: string | null } | undefined,
156
+ fromAgentId: string
157
+ ): string {
158
+ const dn = row?.name
159
+ const sender = typeof dn === 'string' && dn.length > 0
160
+ ? `${dn} (${fromAgentId})`
161
+ : fromAgentId.slice(0, 8)
162
+ return `新邮件 from ${sender}, 请调 get_inbox 查看`
163
+ }
164
+
165
+ export function createAutoPokeImpl(
166
+ db: Database.Database,
167
+ _agents: AgentsRepo,
168
+ channelWakeFanout?: ChannelWakeFanout
169
+ ): import('./auto-poke-fanout.js').AutoPokeFn {
170
+ return async (args) => {
171
+ const row = db
172
+ .prepare('SELECT name FROM agents WHERE agent_id=?')
173
+ .get(args.fromAgentId) as { name: string | null } | undefined
174
+ const hint = buildAutoPokeHint(row, args.fromAgentId)
175
+ const res = await poke(
176
+ { db, callerAgentId: args.fromAgentId, allowCrossTeam: true, channelWakeFanout },
177
+ { target_agent_id: args.targetAgentId, prompt: hint }
178
+ )
179
+ if ('ok' in res && res.ok) return { ok: true }
180
+ const err = (res as { error?: string }).error
181
+ if (err === 'tmux_unavailable') return { ok: false, reason: 'tmux_unavailable' }
182
+ if (err === 'tmux_pane_not_set') return { ok: false, reason: 'no_pane' }
183
+ if (err === 'no_transport_available') return { ok: false, reason: 'no_pane' }
184
+ if (err === 'self_poke_denied') return { ok: false, reason: 'self' }
185
+ return { ok: false, reason: 'guard_failed' }
186
+ }
187
+ }
188
+
189
+ export interface RegisterSuccessHook {
190
+ (agent_id: string, team: string): void
191
+ }
192
+
193
+ export interface UnregisterSuccessHook {
194
+ (agent_id: string): void
195
+ }
196
+
197
+ export interface TransportLike {
198
+ send(msg: Record<string, unknown>): Promise<void> | void
199
+ }
200
+
201
+ export interface SessionClientInfo {
202
+ name?: string
203
+ version?: string
204
+ }
205
+
206
+ function inferRuntimeAgentKind(
207
+ args: { client?: ClientKind; delivery?: { kind?: string }; model: string },
208
+ clientInfo: SessionClientInfo | undefined
209
+ ): DetectAgentKind | undefined {
210
+ if (args.client === 'custom') return undefined
211
+ if (args.client) return args.client
212
+ if (args.delivery?.kind === 'codex-appserver') return 'codex'
213
+
214
+ const raw = `${clientInfo?.name ?? ''} ${clientInfo?.version ?? ''} ${args.model}`.toLowerCase()
215
+ if (raw.includes('codex')) return 'codex'
216
+ if (raw.includes('gpt-')) return 'codex'
217
+ if (raw.includes('claude')) return 'claude-code'
218
+ if (raw.includes('opus') || raw.includes('sonnet')) return 'claude-code'
219
+ if (raw.includes('opencode')) return 'opencode'
220
+ return undefined
221
+ }
222
+
223
+ export function registerBusinessTools(
224
+ server: McpServer,
225
+ db: Database.Database,
226
+ getCallerAgentId: () => string | undefined,
227
+ fanout?: SseFanout,
228
+ onRegisterSuccess?: RegisterSuccessHook,
229
+ getSessionId?: () => string | undefined,
230
+ channelWakeFanout?: ChannelWakeFanout,
231
+ getTransport?: () => TransportLike,
232
+ getSessionClientInfo?: () => SessionClientInfo | undefined,
233
+ onUnregisterSuccess?: UnregisterSuccessHook
234
+ ): void {
235
+ const agents = new AgentsRepo(db)
236
+ const events = new EventsOutbox(db)
237
+ const registerSvc = new RegisterAgentService(db)
238
+ const bindRuntimeIdentitySvc = new BindRuntimeIdentityService(db)
239
+ const registerCodexSelfSvc = new RegisterCodexSelfService(registerSvc)
240
+ const unregisterSelfSvc = new UnregisterSelfService(db, agents)
241
+
242
+ const autoPokeImpl = createAutoPokeImpl(db, agents, channelWakeFanout)
243
+
244
+ const sendSvc = new SendMessageService(db, agents, events, { poke: autoPokeImpl })
245
+ const broadcastSvc = new BroadcastService(db, agents, { poke: autoPokeImpl })
246
+ const broadcastToRoleSvc = new BroadcastToRoleService(db, agents, events, { poke: autoPokeImpl })
247
+ const inboxSvc = new GetInboxService(db, agents)
248
+ const deliveryStatusSvc = new GetDeliveryStatusService(db)
249
+ const taskAddSvc = new TaskAddService(db, agents, events)
250
+ const taskClaimSvc = new TaskClaimService(db, agents, events)
251
+ const taskCompleteSvc = new TaskCompleteService(db, agents, events)
252
+ const taskListSvc = new TaskListService(db, agents)
253
+ const regContractSvc = new RegisterContractService(db, agents, events)
254
+ const subContractSvc = new SubscribeContractService(db, agents)
255
+ const getContractSvc = new GetContractService(db, agents)
256
+ const diffContractsSvc = new DiffContractsService(db, agents)
257
+ const pendingEventsSvc = new PendingContractEventsService(db, agents)
258
+ const codexPanePreRegRepo = new CodexPanePreRegRepo(db)
259
+ const preRegisterCodexPaneSvc = new PreRegisterCodexPaneService(codexPanePreRegRepo)
260
+
261
+ function caller(): string | undefined { return getCallerAgentId() }
262
+
263
+ async function run(fn: () => unknown): Promise<TextContent> {
264
+ const out = await wrapStorage(() => fn())
265
+ touchIfRegistered()
266
+ return toText(out)
267
+ }
268
+
269
+ function touchIfRegistered(): void {
270
+ const c = caller()
271
+ if (!c) return
272
+ try {
273
+ if (agents.findById(c)) agents.touch(c)
274
+ } catch { /* best-effort */ }
275
+ }
276
+
277
+ function requireAgent(): string | { error: 'unknown_agent' } {
278
+ const c = caller()
279
+ if (!c) return { error: 'unknown_agent' }
280
+ const row = agents.findById(c)
281
+ if (!row) return { error: 'unknown_agent' }
282
+ return c
283
+ }
284
+
285
+ async function autoBindRuntimeIdentity(
286
+ args: {
287
+ client?: ClientKind
288
+ model: string
289
+ delivery?: { kind?: string }
290
+ ui_pid?: number
291
+ },
292
+ callerAgentId: string
293
+ ): Promise<boolean> {
294
+ const inferredAgent = inferRuntimeAgentKind(args, getSessionClientInfo?.())
295
+ if (!inferredAgent) return false
296
+
297
+ if (args.ui_pid !== undefined) {
298
+ const boundByPid = await bindRuntimeIdentitySvc.bind({
299
+ callerAgentId,
300
+ agent: inferredAgent,
301
+ ui_pid: args.ui_pid,
302
+ })
303
+ return 'ok' in boundByPid && boundByPid.ok
304
+ }
305
+
306
+ if (inferredAgent === 'codex') {
307
+ const auto = await autoBindCodexPane({
308
+ callerAgentId,
309
+ repo: codexPanePreRegRepo,
310
+ bindRuntimeIdentitySvc,
311
+ })
312
+ if (auto) return true
313
+ }
314
+
315
+ const detected = await detectTmuxPane({ agent: inferredAgent })
316
+ if (!('ok' in detected) || !detected.ok) return false
317
+
318
+ const bound = await bindRuntimeIdentitySvc.bind({
319
+ callerAgentId,
320
+ agent: inferredAgent,
321
+ ui_tty: detected.pane.tty,
322
+ tmux_pane_id: detected.pane.pane_id,
323
+ })
324
+ return 'ok' in bound && bound.ok
325
+ }
326
+
327
+ async function preflightUiPidClient(
328
+ args: {
329
+ client?: ClientKind
330
+ model: string
331
+ delivery?: { kind?: string }
332
+ ui_pid?: number
333
+ }
334
+ ): Promise<
335
+ | undefined
336
+ | {
337
+ error: 'ui_pid_client_mismatch'
338
+ detail: string
339
+ }
340
+ > {
341
+ if (args.ui_pid === undefined) return undefined
342
+ const inferredAgent = inferRuntimeAgentKind(args, getSessionClientInfo?.())
343
+ if (!inferredAgent) return undefined
344
+
345
+ const validated = await bindRuntimeIdentity({
346
+ agent: inferredAgent,
347
+ ui_pid: args.ui_pid,
348
+ })
349
+ if (!('error' in validated) || validated.error !== 'agent_process_mismatch') {
350
+ return undefined
351
+ }
352
+
353
+ return {
354
+ error: 'ui_pid_client_mismatch',
355
+ detail:
356
+ `ui_pid ${args.ui_pid} does not belong to client=\"${inferredAgent}\". ` +
357
+ 'Pass the runtime kind for the process behind ui_pid; for example, use client="opencode" when ui_pid points at an opencode process.',
358
+ }
359
+ }
360
+
361
+ const registerAgentInputSchema = z.object({
362
+ model: z.string(),
363
+ name: z.string().min(1).refine(v => v.trim().length > 0, { message: 'name must not be empty' }),
364
+ role: z.string().optional(),
365
+ team: z.string().optional(),
366
+ project_dir: z.string().min(1).optional(),
367
+ client: clientSchema,
368
+ client_name: z.string().min(1).optional(),
369
+ ui_pid: z.number().int().positive().optional().describe(
370
+ 'STRONGLY RECOMMENDED. Visible agent UI process pid (e.g. Claude Code CLI pid — `$PPID` from a Bash tool call inside Claude Code). Enables one-shot pid → tty → pane binding at registration; without it, tmux-based cross-agent poke delivery typically stays off.'
371
+ ),
372
+ channel_session_id: z.string().min(1).optional(),
373
+ thread_id: z.string().min(1).refine(v => v.trim().length > 0, { message: 'thread_id must not be empty' }).optional(),
374
+ ws_url: z.string().optional(),
375
+ auth_token_ref: z.string().min(1).optional(),
376
+ claude_ui_pid: z.number().int().positive().optional().describe(
377
+ "Internal field for the cross-agent-teams-mcp channel proxy. Stores the proxy's parent Claude Code UI pid (`process.ppid`) so that Claude Code hosts registering in the same lineage can auto-bind their claude-channel delivery. Only valid when role='__channel_proxy__'; rejected otherwise."
378
+ ),
379
+ delivery: deliverySchema.optional(),
380
+ }).strict()
381
+
382
+ const registerAgentArgsSchema = registerAgentInputSchema.superRefine((value, ctx) => {
383
+ const hasCodexFields =
384
+ value.thread_id !== undefined ||
385
+ value.ws_url !== undefined ||
386
+ value.auth_token_ref !== undefined
387
+ if (hasCodexFields && value.client !== 'codex') {
388
+ ctx.addIssue({
389
+ code: z.ZodIssueCode.custom,
390
+ path: ['client'],
391
+ message: 'client=codex is required when thread_id, ws_url, or auth_token_ref is provided',
392
+ })
393
+ }
394
+ if (value.channel_session_id !== undefined && value.client !== 'claude-code') {
395
+ ctx.addIssue({
396
+ code: z.ZodIssueCode.custom,
397
+ path: ['client'],
398
+ message: 'client=claude-code is required when channel_session_id is provided',
399
+ })
400
+ }
401
+ if (value.client_name !== undefined && value.client !== 'custom') {
402
+ ctx.addIssue({
403
+ code: z.ZodIssueCode.custom,
404
+ path: ['client_name'],
405
+ message: 'client_name is only allowed when client=custom',
406
+ })
407
+ }
408
+ if (value.claude_ui_pid !== undefined && value.role !== '__channel_proxy__') {
409
+ ctx.addIssue({
410
+ code: z.ZodIssueCode.custom,
411
+ path: ['claude_ui_pid'],
412
+ message: "claude_ui_pid is only allowed when role='__channel_proxy__'",
413
+ })
414
+ }
415
+ })
416
+
417
+ const registerClaudeSelfInputSchema = z.object({
418
+ name: z.string().min(1).refine(v => v.trim().length > 0, { message: 'name must not be empty' }),
419
+ model: z.string().optional(),
420
+ role: z.string().optional(),
421
+ team: z.string().optional(),
422
+ project_dir: z.string().min(1).optional(),
423
+ ui_pid: z.number().int().positive().optional().describe(
424
+ 'STRONGLY RECOMMENDED. The Claude Code CLI pid (obtainable as `$PPID` from a Bash tool call inside Claude Code). Enables one-shot pid → tty → pane binding at registration; without it, tmux-based cross-agent poke delivery typically stays off until a separate `bind_runtime_identity(...)` call.'
425
+ ),
426
+ channel_session_id: z.string().min(1).optional(),
427
+ }).strict()
428
+
429
+ const registerCodexSelfInputSchema = z.object({
430
+ name: z.string().min(1).refine(v => v.trim().length > 0, { message: 'name must not be empty' }),
431
+ model: z.string().optional(),
432
+ role: z.string().optional(),
433
+ team: z.string().optional(),
434
+ project_dir: z.string().min(1).optional(),
435
+ thread_id: z.string().min(1).refine(v => v.trim().length > 0, { message: 'thread_id must not be empty' }).optional(),
436
+ ws_url: z.string().min(1).optional(),
437
+ auth_token_ref: z.string().min(1).optional(),
438
+ }).strict()
439
+
440
+ async function executeRegister(
441
+ args: {
442
+ client?: ClientKind
443
+ client_name?: string
444
+ model: string
445
+ name: string
446
+ role?: string
447
+ team?: string
448
+ project_dir?: string
449
+ ui_pid?: number
450
+ channel_session_id?: string
451
+ thread_id?: string
452
+ ws_url?: string
453
+ auth_token_ref?: string
454
+ claude_ui_pid?: number
455
+ delivery?: { kind: string; [key: string]: unknown }
456
+ }
457
+ ): Promise<unknown> {
458
+ let nativeDeliveryBound = suppressTmuxHint(args)
459
+ let autoBoundChannelCsid: string | undefined
460
+ const bindChannelSvc = channelWakeFanout
461
+ ? new BindChannelService(db, channelWakeFanout)
462
+ : undefined
463
+ const autoBindChannelSvc = channelWakeFanout
464
+ ? new AutoBindChannelService(db, channelWakeFanout)
465
+ : undefined
466
+ const connectionId = getSessionId?.() ?? caller()
467
+ if (!connectionId) return { error: 'unknown_agent' }
468
+ const uiPidClientError = await preflightUiPidClient(args)
469
+ if (uiPidClientError) return uiPidClientError
470
+ if (
471
+ args.client === 'claude-code' &&
472
+ args.channel_session_id !== undefined &&
473
+ args.ui_pid !== undefined &&
474
+ autoBindChannelSvc
475
+ ) {
476
+ const proxyLookup = autoBindChannelSvc.lookup({
477
+ ui_pid: args.ui_pid,
478
+ })
479
+ if (
480
+ proxyLookup.ok &&
481
+ proxyLookup.channel_session_id !== args.channel_session_id
482
+ ) {
483
+ return {
484
+ error: 'channel_session_id_ui_pid_mismatch',
485
+ detail: {
486
+ ui_pid_matched_csid: proxyLookup.channel_session_id,
487
+ supplied_csid: args.channel_session_id,
488
+ },
489
+ }
490
+ }
491
+ }
492
+ const hasCodexTransportFields =
493
+ args.thread_id !== undefined ||
494
+ args.ws_url !== undefined ||
495
+ args.auth_token_ref !== undefined
496
+ const res =
497
+ args.client === 'codex' &&
498
+ args.delivery === undefined &&
499
+ hasCodexTransportFields
500
+ ? await registerCodexSelfSvc.register({
501
+ connection_id: connectionId,
502
+ name: args.name,
503
+ model: args.model,
504
+ role: args.role,
505
+ team: args.team,
506
+ project_dir: args.project_dir,
507
+ thread_id: args.thread_id,
508
+ ws_url: args.ws_url,
509
+ auth_token_ref: args.auth_token_ref,
510
+ })
511
+ : registerSvc.register({
512
+ connection_id: connectionId,
513
+ client: args.client,
514
+ client_name: args.client_name,
515
+ model: args.model,
516
+ name: args.name,
517
+ role: args.role,
518
+ team: args.team,
519
+ project_dir: args.project_dir,
520
+ delivery: args.delivery,
521
+ claude_ui_pid: args.claude_ui_pid,
522
+ runtime_ui_pid:
523
+ args.client === 'claude-code' ? args.ui_pid : undefined,
524
+ })
525
+ if ('thread_id' in res && 'agent_id' in res) {
526
+ nativeDeliveryBound = true
527
+ }
528
+ if ('agent_id' in res) {
529
+ if (onRegisterSuccess) {
530
+ try { onRegisterSuccess(res.agent_id, res.team) } catch { /* best-effort */ }
531
+ } else if (fanout) {
532
+ try { fanout.rebind(res.agent_id, res.team) } catch { /* best-effort */ }
533
+ }
534
+ if (args.client === 'claude-code' && args.channel_session_id !== undefined) {
535
+ const channelBind = bindChannelSvc
536
+ ? bindChannelSvc.bind({
537
+ callerAgentId: res.agent_id,
538
+ channel_session_id: args.channel_session_id,
539
+ })
540
+ : { error: 'unknown_channel_session' as const }
541
+ if ('ok' in channelBind && channelBind.ok) {
542
+ nativeDeliveryBound = true
543
+ } else {
544
+ return channelBind
545
+ }
546
+ }
547
+ if (
548
+ args.client === 'claude-code' &&
549
+ args.channel_session_id === undefined &&
550
+ args.ui_pid !== undefined &&
551
+ autoBindChannelSvc
552
+ ) {
553
+ const autoBind = autoBindChannelSvc.run({
554
+ callerAgentId: res.agent_id,
555
+ ui_pid: args.ui_pid,
556
+ })
557
+ if (autoBind.ok) {
558
+ autoBoundChannelCsid = autoBind.channel_session_id
559
+ nativeDeliveryBound = true
560
+ }
561
+ }
562
+ const autoBound = await autoBindRuntimeIdentity(args, res.agent_id)
563
+ const envelope = autoBoundChannelCsid !== undefined
564
+ ? { ...res, channel_session_id: autoBoundChannelCsid }
565
+ : res
566
+ if (autoBound) return envelope
567
+ if (!nativeDeliveryBound) {
568
+ return {
569
+ ...envelope,
570
+ hint: "No usable tmux_pane_id is bound yet — automatic runtime binding did not converge for this session, so cross-agent poke delivery via tmux is still off. Call `bind_runtime_identity(...)` to bind explicitly, or use `detect_tmux_pane(...)` for debugging. Claude Code users who loaded the cross-agent-teams-mcp channel plugin can also route pokes via channel_session_id — that path does not require tmux binding."
571
+ }
572
+ }
573
+ return envelope
574
+ }
575
+ return res
576
+ }
577
+
578
+ function releaseRegisteredState(agentId: string): void {
579
+ const connectionId = getSessionId?.()
580
+ if (connectionId) registerSvc.releaseConnection(agentId, connectionId)
581
+ if (onUnregisterSuccess) {
582
+ try { onUnregisterSuccess(agentId) } catch { /* best-effort */ }
583
+ return
584
+ }
585
+ if (fanout) {
586
+ try { fanout.detach(agentId) } catch { /* best-effort */ }
587
+ }
588
+ }
589
+
590
+ // pre_register_codex_pane — callable by launchers before any agent row exists
591
+ server.registerTool(
592
+ 'pre_register_codex_pane',
593
+ {
594
+ title: 'Pre-register codex tmux pane',
595
+ description: [
596
+ 'Pre-register a pending tmux-pane claim so the launcher can claim a tmux pane before starting codex.',
597
+ 'The launcher should call this with `$TMUX_PANE` and a freshly generated UUID, then `exec codex --remote ... -c xats.agent_id="\\"<uuid>\\""`.',
598
+ 'When the codex agent later calls `register_agent({client:"codex"})` without `ui_pid`, the daemon uses the pending row to resolve the correct UI pid and auto-bind the pane.',
599
+ 'Callable without a prior `register_agent` — launchers have no agent identity yet.',
600
+ 'TTL defaults to 120 seconds and is capped at 600; pending rows are garbage-collected opportunistically.',
601
+ ].join(' '),
602
+ inputSchema: preRegisterCodexPaneInputSchema,
603
+ },
604
+ async (args: unknown) => run(async () => preRegisterCodexPaneSvc.register(args))
605
+ )
606
+
607
+ // register_agent — bootstrap: callable before an agents row exists for this session
608
+ server.registerTool(
609
+ 'detect_tmux_pane',
610
+ {
611
+ title: 'Detect tmux pane',
612
+ description: [
613
+ 'Detect the tmux pane that is actually hosting a coding agent UI, even when the shell calling tools lives in a different pane.',
614
+ 'The detector scans tmux panes globally, maps each pane to its tty, then inspects real tty processes instead of trusting `$TMUX_PANE` or tmux focus state alone.',
615
+ 'Use `agent` to pick a built-in matcher for Codex, Claude Code, or opencode.',
616
+ 'Optional `cwd`, `tty`, and `title_contains` narrow the search and make cross-directory multi-agent sessions much more reliable.',
617
+ 'Returns either a single best pane, or an ambiguity/not-found result with candidates for debugging.'
618
+ ].join(' '),
619
+ inputSchema: detectTmuxPaneSchema,
620
+ },
621
+ async (args: {
622
+ agent: 'codex' | 'claude-code' | 'opencode' | 'custom'
623
+ cwd?: string
624
+ tty?: string
625
+ title_contains?: string
626
+ process_pattern?: string
627
+ }) => run(async () => {
628
+ const parsed = detectTmuxPaneArgsSchema.safeParse(args)
629
+ if (!parsed.success) {
630
+ return {
631
+ error: 'invalid_arguments' as const,
632
+ detail: parsed.error.issues.map(issue => issue.message).join('; '),
633
+ }
634
+ }
635
+ return detectTmuxPane({
636
+ agent: parsed.data.agent,
637
+ cwd: parsed.data.cwd,
638
+ tty: parsed.data.tty,
639
+ title_contains: parsed.data.title_contains,
640
+ process_pattern: parsed.data.process_pattern,
641
+ })
642
+ })
643
+ )
644
+
645
+ server.registerTool(
646
+ 'register_agent',
647
+ {
648
+ title: 'Register agent',
649
+ description: [
650
+ 'Register this session as an agent in a team.',
651
+ 'This is the unified registration entry point.',
652
+ 'Calling this tool again with the same `(team, name, role)` identity reuses the existing',
653
+ '`agent_id` and refreshes `tmux_pane_id` and `model`; no duplicate row is created.',
654
+ 'Callers MUST pass `client` explicitly.',
655
+ 'Use `client="custom"` for unsupported agent harnesses; optionally provide `client_name` for observability.',
656
+ 'Claude Code sessions can pass `client="claude-code"` together with `channel_session_id` to bind channel delivery through this same tool. PREFERRED: pass only `ui_pid` (from `$PPID`) and let the daemon auto-bind channel delivery — do not pass `channel_session_id` explicitly on register. When BOTH `ui_pid` AND `channel_session_id` are supplied, the daemon runs a consistency check against the caller `ui_pid`\'s live channel proxy; if the proxy\'s csid does not match the supplied `channel_session_id`, the call is rejected with `channel_session_id_ui_pid_mismatch` before any agent row is written. Use `bind_channel` for low-level rebind after registration instead of supplying csid here.',
657
+ 'Codex sessions can pass `client="codex"` together with `thread_id` to register Codex app-server delivery through this same tool.',
658
+ 'Codex clients SHOULD prefer `register_codex_self` instead — it is the codex-specific convenience entry point. Do NOT pass `ui_pid` from codex agents: the launcher\'s `pre_register_codex_pane` pre-reg flow handles tmux pane binding, and supplying `ui_pid` from codex disables that auto-bind path.',
659
+ 'Requests such as "register to xats" or "register to cross-agent-teams" refer to this MCP service, not to the `team` field; do not set `team` to `xats` or `cross-agent-teams` from those phrases.',
660
+ 'Do not treat the bare word "register" as a request for this tool unless the current conversation is already about cross-agent-teams registration.',
661
+ 'When the end user has not explicitly specified `team`, callers should pass `project_dir` as the current working directory so the daemon derives a project-scoped default team from its basename; if omitted, it falls back to `default`.',
662
+ '`client` must describe the runtime behind `ui_pid`, not merely the current MCP caller. For example, if `ui_pid` points at an opencode process, pass `client="opencode"` even when the registration request is issued from Claude Code.',
663
+ 'STRONGLY RECOMMENDED: pass `ui_pid` unless it is truly unobtainable. Without it, automatic runtime binding usually fails to converge and tmux-based cross-agent poke delivery stays off until a separate `bind_runtime_identity(...)` call. From Claude Code, `$PPID` inside a Bash tool call is the `claude` CLI pid; for Codex/opencode/other harnesses, discover the UI pid from the host harness. With `ui_pid` the daemon binds via verified pid → tty → pane evidence in one shot.',
664
+ 'After registration, the daemon best-effort attempts runtime binding for recognized local clients so tmux-based poke delivery can come up without a second tool call.',
665
+ 'If automatic runtime binding does not converge, call `bind_runtime_identity(...)` explicitly so the daemon can verify and persist your pane binding.',
666
+ '`detect_tmux_pane(...)` remains available as a debugging aid for ambiguous or missing matches, but it does not write registry state by itself.',
667
+ 'When registration still has no usable `tmux_pane_id`, tmux-based poke delivery stays unavailable until automatic or explicit runtime binding succeeds.'
668
+ ].join(' '),
669
+ inputSchema: registerAgentInputSchema
670
+ },
671
+ async (args: {
672
+ client: ClientKind
673
+ client_name?: string
674
+ model: string; name: string; role?: string; team?: string;
675
+ project_dir?: string;
676
+ ui_pid?: number;
677
+ channel_session_id?: string
678
+ thread_id?: string
679
+ ws_url?: string
680
+ auth_token_ref?: string
681
+ claude_ui_pid?: number
682
+ delivery?: { kind: string; [key: string]: unknown }
683
+ }) => {
684
+ return run(async () => executeRegister(registerAgentArgsSchema.parse(args)))
685
+ }
686
+ )
687
+
688
+ server.registerTool(
689
+ 'register_claude_self',
690
+ {
691
+ title: 'Register Claude Code session',
692
+ description: [
693
+ 'Register the current Claude Code MCP session as an agent.',
694
+ 'Prefer this helper inside Claude Code when you want to avoid session-mismatch issues caused by external HTTP or curl registration.',
695
+ 'This tool always writes on the caller\'s current MCP session, so follow-up tools like get_inbox use the same identity immediately.',
696
+ 'AUTO-BIND: when `ui_pid` is supplied AND `channel_session_id` is omitted, the daemon best-effort looks up a live `__channel_proxy__` row whose `claude_ui_pid` matches the caller `ui_pid` and auto-binds `delivery.kind=\"claude-channel\"` to the proxy\'s current csid. On success the response includes `channel_session_id`. No match → delivery stays `none` (no error surfaced). This makes `ui_pid` sufficient for channel delivery — the LLM does not need to read or pass csid explicitly.',
697
+ 'If `channel_session_id` is supplied explicitly, the explicit value wins and auto-bind is skipped (identical semantics to `bind_channel`).',
698
+ 'When BOTH `ui_pid` AND `channel_session_id` are supplied, the daemon runs a consistency check: it looks up the live `__channel_proxy__` row matching the caller `ui_pid` and team, and if that proxy\'s persisted csid differs from the supplied `channel_session_id`, the call is rejected with `channel_session_id_ui_pid_mismatch` BEFORE any agent row is written. Prefer ui_pid-only registration (no `channel_session_id`) to avoid this class of stale-csid drift.',
699
+ 'Requests such as "register to xats" or "register to cross-agent-teams" refer to this MCP service, not to the `team` field; do not set `team` to `xats` or `cross-agent-teams` from those phrases.',
700
+ 'Do not treat the bare word "register" as a request for this tool unless the current conversation is already about cross-agent-teams registration.',
701
+ 'When the end user has not explicitly specified `team`, callers should pass `project_dir` as the current working directory so the daemon derives a project-scoped default team from its basename; if omitted, it falls back to `default`.',
702
+ 'STRONGLY RECOMMENDED: pass `ui_pid` (the Claude Code CLI pid — obtainable as `$PPID` from a Bash tool call). Without it, both channel auto-bind AND automatic tmux runtime binding stay off until a separate `bind_runtime_identity(...)` call. With `ui_pid` the daemon can auto-bind the channel delivery AND verify pid → tty → pane evidence in one shot.',
703
+ 'model is optional here; when omitted it falls back to a Claude-specific default.'
704
+ ].join(' '),
705
+ inputSchema: registerClaudeSelfInputSchema
706
+ },
707
+ async (args: {
708
+ name: string
709
+ model?: string
710
+ role?: string
711
+ team?: string
712
+ project_dir?: string
713
+ ui_pid?: number
714
+ channel_session_id?: string
715
+ }) => run(async () => executeRegister({
716
+ client: 'claude-code',
717
+ name: args.name,
718
+ model: args.model ?? defaultClaudeSelfModel(getSessionClientInfo?.()),
719
+ role: args.role,
720
+ team: args.team,
721
+ project_dir: args.project_dir,
722
+ ui_pid: args.ui_pid,
723
+ channel_session_id: args.channel_session_id,
724
+ }))
725
+ )
726
+
727
+ server.registerTool(
728
+ 'register_codex_self',
729
+ {
730
+ title: 'Register codex MCP session',
731
+ description: [
732
+ 'Register the current codex MCP session as an agent bound to `codex-appserver` delivery.',
733
+ 'Prefer this helper inside codex over the generic `register_agent` — it locks `client="codex"` and keeps the input surface minimal.',
734
+ 'THREAD_ID: read `$CODEX_THREAD_ID` from your tool shell environment (codex 0.124.0+ exports it for every MCP tool subprocess) and pass its value as `thread_id`. When `thread_id` is omitted, the daemon returns the existing `thread_id_required` envelope listing resumable thread_ids as a candidate fallback.',
735
+ 'DO NOT pass `ui_pid`: this tool\'s schema rejects it outright. UI pid discovery and tmux pane binding are handled automatically by the launcher\'s `pre_register_codex_pane` pre-reg flow — passing `ui_pid` would silently disable that auto-bind path.',
736
+ 'The daemon connects to `ws_url` (default `ws://127.0.0.1:8799`, env override `CROSS_AGENT_TEAMS_CODEX_WS_URL`), runs the codex `initialize` + `thread/resume` handshake, and writes `delivery.kind="codex-appserver"`.',
737
+ 'Requests such as "register to xats" or "register to cross-agent-teams" refer to this MCP service, not to the `team` field; do not set `team` to `xats` or `cross-agent-teams` from those phrases.',
738
+ 'When the end user has not explicitly specified `team`, callers should pass `project_dir` as the current working directory so the daemon derives a project-scoped default team from its basename; if omitted, it falls back to `default`.',
739
+ 'model is optional here; when omitted it falls back to `gpt`.',
740
+ ].join(' '),
741
+ inputSchema: registerCodexSelfInputSchema,
742
+ },
743
+ async (args: {
744
+ name: string
745
+ model?: string
746
+ role?: string
747
+ team?: string
748
+ project_dir?: string
749
+ thread_id?: string
750
+ ws_url?: string
751
+ auth_token_ref?: string
752
+ }) => run(async () => executeRegister({
753
+ client: 'codex',
754
+ name: args.name,
755
+ model: args.model ?? 'gpt',
756
+ role: args.role,
757
+ team: args.team,
758
+ project_dir: args.project_dir,
759
+ thread_id: args.thread_id,
760
+ // Ensure the codex-appserver path is always taken even when thread_id is
761
+ // omitted: callers of register_codex_self expect the thread_id_required
762
+ // candidate-list fallback, not a plain delivery=none registration.
763
+ // RegisterCodexSelfService resolves the empty string to env/default ws_url.
764
+ ws_url: args.ws_url ?? '',
765
+ auth_token_ref: args.auth_token_ref,
766
+ }))
767
+ )
768
+
769
+ server.registerTool(
770
+ 'unregister_self',
771
+ {
772
+ title: 'Unregister current agent',
773
+ description: [
774
+ 'Remove the caller session\'s current agent registration.',
775
+ 'This tool only unregisters the currently bound agent identity; it does not delete other agents.',
776
+ 'If the caller still owns any in-progress task, it returns `tasks_in_progress` and leaves all state unchanged.',
777
+ 'On success it deletes the agent row, removes the caller\'s contract subscriptions, and immediately releases the current MCP session back to an unregistered state.'
778
+ ].join(' '),
779
+ inputSchema: z.object({}).strict()
780
+ },
781
+ async () => {
782
+ const who = requireAgent()
783
+ if (typeof who !== 'string') return toText(who)
784
+ const result = await wrapStorage(() => unregisterSelfSvc.unregister({ caller: who }))
785
+ if (
786
+ typeof result === 'object' &&
787
+ result !== null &&
788
+ 'ok' in result &&
789
+ result.ok === true &&
790
+ 'agent_id' in result &&
791
+ typeof result.agent_id === 'string'
792
+ ) {
793
+ releaseRegisteredState(result.agent_id)
794
+ return toText(result)
795
+ }
796
+ touchIfRegistered()
797
+ return toText(result)
798
+ }
799
+ )
800
+
801
+ // list_agents
802
+ server.registerTool(
803
+ 'list_agents',
804
+ {
805
+ title: 'List agents',
806
+ description: 'List agents in the caller\'s team',
807
+ inputSchema: {}
808
+ },
809
+ async () => {
810
+ const who = requireAgent()
811
+ if (typeof who !== 'string') return toText(who)
812
+ const row = agents.findById(who)!
813
+ return run(() => ({
814
+ agents: agents.list({ team: row.team }).map(toPublicAgentRow),
815
+ }))
816
+ }
817
+ )
818
+
819
+ // send_message (by name)
820
+ server.registerTool(
821
+ 'send_message',
822
+ {
823
+ title: 'Send message',
824
+ description: SEND_MESSAGE_DESC,
825
+ inputSchema: z.object({
826
+ to_agent_name: z.string().min(1),
827
+ to_team: z.string().min(1).optional(),
828
+ subject: z.string().optional(),
829
+ body: z.string().min(1),
830
+ auto_poke: z.boolean().optional(),
831
+ need_reply: z.boolean().optional()
832
+ }).strict()
833
+ },
834
+ async (args: {
835
+ to_agent_name: string
836
+ to_team?: string
837
+ subject?: string
838
+ body: string
839
+ auto_poke?: boolean
840
+ need_reply?: boolean
841
+ }) => {
842
+ const who = requireAgent()
843
+ if (typeof who !== 'string') return toText(who)
844
+ return run(() => sendSvc.send({ from: who, ...args }))
845
+ }
846
+ )
847
+
848
+ // send_message_by_id (by UUID)
849
+ server.registerTool(
850
+ 'send_message_by_id',
851
+ {
852
+ title: 'Send message by id',
853
+ description: SEND_MESSAGE_BY_ID_DESC,
854
+ inputSchema: z.object({
855
+ to_agent_id: z.string().min(1),
856
+ subject: z.string().optional(),
857
+ body: z.string().min(1),
858
+ auto_poke: z.boolean().optional(),
859
+ need_reply: z.boolean().optional()
860
+ }).strict()
861
+ },
862
+ async (args: {
863
+ to_agent_id: string
864
+ subject?: string
865
+ body: string
866
+ auto_poke?: boolean
867
+ need_reply?: boolean
868
+ }) => {
869
+ const who = requireAgent()
870
+ if (typeof who !== 'string') return toText(who)
871
+ return run(() => sendSvc.send({ from: who, ...args }))
872
+ }
873
+ )
874
+
875
+ // broadcast
876
+ server.registerTool(
877
+ 'broadcast',
878
+ {
879
+ title: 'Broadcast message',
880
+ description: BROADCAST_DESC,
881
+ inputSchema: {
882
+ subject: z.string().optional(),
883
+ body: z.string(),
884
+ auto_poke: z.boolean().optional()
885
+ }
886
+ },
887
+ async (args: { subject?: string; body: string; auto_poke?: boolean }) => {
888
+ const who = requireAgent()
889
+ if (typeof who !== 'string') return toText(who)
890
+ return run(() => broadcastSvc.broadcast({ from: who, ...args }))
891
+ }
892
+ )
893
+
894
+ // broadcast_to_role
895
+ server.registerTool(
896
+ 'broadcast_to_role',
897
+ {
898
+ title: 'Broadcast to role',
899
+ description: BROADCAST_TO_ROLE_DESC,
900
+ inputSchema: z.object({
901
+ to_role: z.string().min(1),
902
+ subject: z.string().optional(),
903
+ body: z.string().min(1),
904
+ auto_poke: z.boolean().optional()
905
+ }).strict()
906
+ },
907
+ async (args: { to_role: string; subject?: string; body: string; auto_poke?: boolean }) => {
908
+ const who = requireAgent()
909
+ if (typeof who !== 'string') return toText(who)
910
+ return run(() => broadcastToRoleSvc.broadcast({ from: who, ...args }))
911
+ }
912
+ )
913
+
914
+ // get_inbox
915
+ server.registerTool(
916
+ 'get_inbox',
917
+ {
918
+ title: 'Get inbox',
919
+ description: 'Return messages addressed to caller after since_event_id',
920
+ inputSchema: {
921
+ since_event_id: z.number().int().optional(),
922
+ limit: z.number().int().optional()
923
+ }
924
+ },
925
+ async (args: { since_event_id?: number; limit?: number }) => {
926
+ const who = requireAgent()
927
+ if (typeof who !== 'string') return toText(who)
928
+ return run(() => inboxSvc.get({ caller: who, ...args }))
929
+ }
930
+ )
931
+
932
+ // get_delivery_status
933
+ server.registerTool(
934
+ 'get_delivery_status',
935
+ {
936
+ title: 'Get delivery status',
937
+ description: [
938
+ 'Return wake-hint delivery status for a message sent by caller.',
939
+ 'Status describes auto-poke delivery only; mailbox persistence is already complete.',
940
+ 'Only the original sender can read a message delivery status.'
941
+ ].join(' '),
942
+ inputSchema: {
943
+ message_id: z.string()
944
+ }
945
+ },
946
+ async (args: { message_id: string }) => {
947
+ const who = requireAgent()
948
+ if (typeof who !== 'string') return toText(who)
949
+ return run(() => deliveryStatusSvc.get({ caller: who, ...args }))
950
+ }
951
+ )
952
+
953
+ // task_add
954
+ server.registerTool(
955
+ 'task_add',
956
+ {
957
+ title: 'Add task',
958
+ description: [
959
+ "Add a new task to the team's task list. Any team member can claim it via `task_claim`",
960
+ 'on their next turn. The task will sit in the pending queue until someone pulls `task_list`.',
961
+ '`task_add` itself does not wake or target any specific agent; use normal mailbox messaging',
962
+ 'when coordination is needed, then inspect that message with `get_delivery_status`.'
963
+ ].join(' '),
964
+ inputSchema: {
965
+ title: z.string(),
966
+ description: z.string().optional(),
967
+ depends_on: z.array(z.string()).optional()
968
+ }
969
+ },
970
+ async (args: { title: string; description?: string; depends_on?: string[] }) => {
971
+ const who = requireAgent()
972
+ if (typeof who !== 'string') return toText(who)
973
+ return run(() => taskAddSvc.add({ caller: who, ...args }))
974
+ }
975
+ )
976
+
977
+ // task_claim
978
+ server.registerTool(
979
+ 'task_claim',
980
+ {
981
+ title: 'Claim task',
982
+ description: 'Claim a pending task as caller',
983
+ inputSchema: { task_id: z.string() }
984
+ },
985
+ async (args: { task_id: string }) => {
986
+ const who = requireAgent()
987
+ if (typeof who !== 'string') return toText(who)
988
+ return run(() => taskClaimSvc.claim({ caller: who, task_id: args.task_id }))
989
+ }
990
+ )
991
+
992
+ // task_complete
993
+ server.registerTool(
994
+ 'task_complete',
995
+ {
996
+ title: 'Complete task',
997
+ description: 'Mark the caller\'s in-progress task as completed',
998
+ inputSchema: {
999
+ task_id: z.string(),
1000
+ result: z.string().optional()
1001
+ }
1002
+ },
1003
+ async (args: { task_id: string; result?: string }) => {
1004
+ const who = requireAgent()
1005
+ if (typeof who !== 'string') return toText(who)
1006
+ return run(() => taskCompleteSvc.complete({ caller: who, ...args }))
1007
+ }
1008
+ )
1009
+
1010
+ // task_list
1011
+ server.registerTool(
1012
+ 'task_list',
1013
+ {
1014
+ title: 'List tasks',
1015
+ description: 'List tasks in the caller\'s team, optionally filtered by status',
1016
+ inputSchema: {
1017
+ status: z.enum(['pending', 'in_progress', 'completed']).optional()
1018
+ }
1019
+ },
1020
+ async (args: { status?: 'pending' | 'in_progress' | 'completed' }) => {
1021
+ const who = requireAgent()
1022
+ if (typeof who !== 'string') return toText(who)
1023
+ return run(() => taskListSvc.list({ caller: who, status: args.status }))
1024
+ }
1025
+ )
1026
+
1027
+ // register_contract
1028
+ server.registerTool(
1029
+ 'register_contract',
1030
+ {
1031
+ title: 'Register contract',
1032
+ description: 'Register or upgrade a contract version',
1033
+ inputSchema: {
1034
+ name: z.string(),
1035
+ schema: z.record(z.unknown()),
1036
+ format: z.literal('jsonschema').optional(),
1037
+ note: z.string().optional()
1038
+ }
1039
+ },
1040
+ async (args: { name: string; schema: Record<string, unknown>; format?: 'jsonschema'; note?: string }) => {
1041
+ const who = requireAgent()
1042
+ if (typeof who !== 'string') return toText(who)
1043
+ return run(() => {
1044
+ const res = regContractSvc.register({ caller: who, ...args })
1045
+ if ('version' in res && res._meta && fanout) {
1046
+ try {
1047
+ fanout.emitContractEvent(db, {
1048
+ to_team: res._meta.team,
1049
+ contract_name: res.name,
1050
+ version: res.version,
1051
+ event_id: res._meta.event_id,
1052
+ diff: res._meta.diff
1053
+ })
1054
+ } catch { /* push failure does not roll back event */ }
1055
+ }
1056
+ if ('version' in res) {
1057
+ const { _meta: _omit, ...publicRes } = res
1058
+ return publicRes
1059
+ }
1060
+ return res
1061
+ })
1062
+ }
1063
+ )
1064
+
1065
+ // subscribe_contract
1066
+ server.registerTool(
1067
+ 'subscribe_contract',
1068
+ {
1069
+ title: 'Subscribe contract',
1070
+ description: 'Subscribe the caller to a contract name\'s updates',
1071
+ inputSchema: { name: z.string() }
1072
+ },
1073
+ async (args: { name: string }) => {
1074
+ const who = requireAgent()
1075
+ if (typeof who !== 'string') return toText(who)
1076
+ return run(() => subContractSvc.subscribe({ caller: who, name: args.name }))
1077
+ }
1078
+ )
1079
+
1080
+ // get_contract
1081
+ server.registerTool(
1082
+ 'get_contract',
1083
+ {
1084
+ title: 'Get contract',
1085
+ description: 'Fetch a contract version (latest by default)',
1086
+ inputSchema: {
1087
+ name: z.string(),
1088
+ version: z.number().int().optional()
1089
+ }
1090
+ },
1091
+ async (args: { name: string; version?: number }) => {
1092
+ const who = requireAgent()
1093
+ if (typeof who !== 'string') return toText(who)
1094
+ return run(() => getContractSvc.get({ caller: who, ...args }))
1095
+ }
1096
+ )
1097
+
1098
+ // diff_contracts
1099
+ server.registerTool(
1100
+ 'diff_contracts',
1101
+ {
1102
+ title: 'Diff contracts',
1103
+ description: 'Compute diff between two versions of a contract',
1104
+ inputSchema: {
1105
+ name: z.string(),
1106
+ from_version: z.number().int(),
1107
+ to_version: z.number().int()
1108
+ }
1109
+ },
1110
+ async (args: { name: string; from_version: number; to_version: number }) => {
1111
+ const who = requireAgent()
1112
+ if (typeof who !== 'string') return toText(who)
1113
+ return run(() => diffContractsSvc.diff({ caller: who, ...args }))
1114
+ }
1115
+ )
1116
+
1117
+ // bind_channel — self-binding: caller (Claude host) writes its own channel_session_id
1118
+ if (channelWakeFanout) {
1119
+ const bindSvc = new BindChannelService(db, channelWakeFanout)
1120
+ server.registerTool(
1121
+ 'bind_channel',
1122
+ {
1123
+ title: 'Bind channel_session_id to caller',
1124
+ description: [
1125
+ 'Low-level rebind tool for Claude channel delivery.',
1126
+ 'Bind the caller session\'s agent row to a channel_session_id produced by the cross-agent-teams-mcp channel proxy.',
1127
+ 'Most callers should prefer `register_agent({ client: "claude-code", channel_session_id, ... })` on the unified registration path.',
1128
+ 'Call this when you need to rebind an already-registered row after the proxy announces a new csid.',
1129
+ 'Rejects proxy callers (role=__channel_proxy__).',
1130
+ 'Rejects unknown csid (no live proxy sink attached).'
1131
+ ].join(' '),
1132
+ inputSchema: {
1133
+ channel_session_id: z.string().min(1)
1134
+ }
1135
+ },
1136
+ async (args: { channel_session_id: string }) => {
1137
+ const who = requireAgent()
1138
+ if (typeof who !== 'string') return toText(who)
1139
+ return run(() => bindSvc.bind({
1140
+ callerAgentId: who,
1141
+ channel_session_id: args.channel_session_id
1142
+ }))
1143
+ }
1144
+ )
1145
+ }
1146
+
1147
+ server.registerTool(
1148
+ 'bind_runtime_identity',
1149
+ {
1150
+ title: 'Bind runtime identity to caller',
1151
+ description: [
1152
+ 'Bind the caller session\'s agent row to a verified tmux runtime identity.',
1153
+ 'Pass `agent` to choose the built-in process matcher (`codex`, `claude-code`, `opencode`), or use `custom` together with `process_pattern`.',
1154
+ 'Prefer passing `ui_pid` for the visible agent UI process; the daemon verifies pid → tty → pane before persisting `tmux_pane_id`.',
1155
+ 'If `ui_pid` is unavailable, pass `ui_tty` together with `tmux_pane_id` for a weaker but still verified binding path.',
1156
+ 'This tool writes registry state; `detect_tmux_pane` is for debugging only.'
1157
+ ].join(' '),
1158
+ inputSchema: bindRuntimeIdentitySchema,
1159
+ },
1160
+ async (args: {
1161
+ agent: 'codex' | 'claude-code' | 'opencode' | 'custom'
1162
+ ui_pid?: number
1163
+ ui_tty?: string
1164
+ tmux_pane_id?: string
1165
+ process_pattern?: string
1166
+ }) => {
1167
+ const parsed = bindRuntimeIdentityArgsSchema.safeParse(args)
1168
+ if (!parsed.success) {
1169
+ return toText({
1170
+ error: 'invalid_arguments',
1171
+ detail: parsed.error.issues.map(issue => issue.message).join('; '),
1172
+ })
1173
+ }
1174
+ const who = requireAgent()
1175
+ if (typeof who !== 'string') return toText(who)
1176
+ return run(() => bindRuntimeIdentitySvc.bind({
1177
+ callerAgentId: who,
1178
+ agent: parsed.data.agent,
1179
+ ui_pid: parsed.data.ui_pid,
1180
+ ui_tty: parsed.data.ui_tty,
1181
+ tmux_pane_id: parsed.data.tmux_pane_id,
1182
+ process_pattern: parsed.data.process_pattern,
1183
+ }))
1184
+ }
1185
+ )
1186
+
1187
+ // subscribe_channel_wake — reserved for channel proxies (role=__channel_proxy__)
1188
+ if (channelWakeFanout) {
1189
+ const subscribeSvc = new SubscribeChannelWakeService(db, channelWakeFanout)
1190
+ server.registerTool(
1191
+ 'subscribe_channel_wake',
1192
+ {
1193
+ title: 'Subscribe channel wake',
1194
+ description: [
1195
+ 'Internal tool reserved for the cross-agent-teams-mcp channel proxy.',
1196
+ 'Attaches the caller\'s MCP session notification sink to a channel_session_id so the',
1197
+ 'daemon can emit notifications/channel_wake to it. Requires role=__channel_proxy__.'
1198
+ ].join(' '),
1199
+ inputSchema: { channel_session_id: z.string().min(1) }
1200
+ },
1201
+ async (args: { channel_session_id: string }) => {
1202
+ const who = requireAgent()
1203
+ if (typeof who !== 'string') return toText(who)
1204
+ const sid = getSessionId?.()
1205
+ if (!sid) return toText({ error: 'unknown_session' })
1206
+ const sink = (payload: unknown) => {
1207
+ const t = getTransport?.()
1208
+ if (!t) return
1209
+ try {
1210
+ void Promise.resolve(t.send(payload as Record<string, unknown>)).catch(() => { /* best-effort */ })
1211
+ } catch { /* best-effort */ }
1212
+ }
1213
+ return run(() => subscribeSvc.subscribe({
1214
+ callerAgentId: who,
1215
+ channel_session_id: args.channel_session_id,
1216
+ sessionId: sid,
1217
+ sink
1218
+ }))
1219
+ }
1220
+ )
1221
+ }
1222
+
1223
+ // pending_contract_events
1224
+ server.registerTool(
1225
+ 'pending_contract_events',
1226
+ {
1227
+ title: 'Pending contract events',
1228
+ description: 'Poll contract_registered events not yet seen',
1229
+ inputSchema: {
1230
+ since_event_id: z.number().int().optional(),
1231
+ limit: z.number().int().optional()
1232
+ }
1233
+ },
1234
+ async (args: { since_event_id?: number; limit?: number }) => {
1235
+ const who = requireAgent()
1236
+ if (typeof who !== 'string') return toText(who)
1237
+ return run(() => pendingEventsSvc.poll({ caller: who, ...args }))
1238
+ }
1239
+ )
1240
+ }