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
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "cross-agent-teams-mcp",
3
+ "version": "0.2.0",
4
+ "description": "MCP daemon for cross-agent collaboration",
5
+ "type": "module",
6
+ "bin": {
7
+ "cross-agent-teams-mcp": "./dist/cli.js",
8
+ "cross-agent-teams-channel": "./dist/channel-cli.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "src",
13
+ "README.md",
14
+ "README.zh-CN.md",
15
+ "LICENSE"
16
+ ],
17
+ "engines": {
18
+ "node": ">=20"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/jtianling/cross-agent-teams-mcp.git"
23
+ },
24
+ "homepage": "https://github.com/jtianling/cross-agent-teams-mcp#readme",
25
+ "bugs": {
26
+ "url": "https://github.com/jtianling/cross-agent-teams-mcp/issues"
27
+ },
28
+ "keywords": [
29
+ "mcp",
30
+ "model-context-protocol",
31
+ "claude-code",
32
+ "codex",
33
+ "agent",
34
+ "multi-agent",
35
+ "cross-agent"
36
+ ],
37
+ "author": "jtianling <jtianling@gmail.com>",
38
+ "license": "MIT",
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "dependencies": {
43
+ "@modelcontextprotocol/sdk": "^1.0.0",
44
+ "better-sqlite3": "^11.3.0",
45
+ "fastify": "^5.0.0",
46
+ "json-schema-diff-validator": "^0.4.2",
47
+ "zod": "^3.23.0"
48
+ },
49
+ "devDependencies": {
50
+ "@types/better-sqlite3": "^7.6.11",
51
+ "@types/node": "^22.0.0",
52
+ "tsup": "^8.3.0",
53
+ "tsx": "^4.21.0",
54
+ "typescript": "^5.6.0",
55
+ "vitest": "^2.1.0"
56
+ },
57
+ "scripts": {
58
+ "build": "tsup",
59
+ "test": "vitest run",
60
+ "typecheck": "tsc --noEmit"
61
+ }
62
+ }
@@ -0,0 +1,130 @@
1
+ import { spawn, type SpawnOptions } from 'node:child_process'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { mkdirSync, openSync } from 'node:fs'
4
+ import { homedir } from 'node:os'
5
+ import { dirname, join } from 'node:path'
6
+
7
+ export interface EnsureDaemonHealthyOptions {
8
+ daemonUrl: string
9
+ log?: NodeJS.WritableStream
10
+ // Test-only injection points; production callers leave these undefined.
11
+ fetchImpl?: typeof fetch
12
+ spawnImpl?: typeof spawn
13
+ daemonEntryOverride?: string
14
+ probeTimeoutMs?: number
15
+ pollAttempts?: number
16
+ pollIntervalMs?: number
17
+ logFilePath?: string
18
+ }
19
+
20
+ const DEFAULT_LOG_PATH = join(homedir(), '.cross-agent-teams-mcp', 'daemon.log')
21
+
22
+ function isLoopbackHost(host: string): boolean {
23
+ return host === '127.0.0.1' || host === 'localhost'
24
+ }
25
+
26
+ function originOf(url: URL): string {
27
+ return `${url.protocol}//${url.host}`
28
+ }
29
+
30
+ async function probeHealth(
31
+ origin: string,
32
+ timeoutMs: number,
33
+ fetchImpl: typeof fetch
34
+ ): Promise<boolean> {
35
+ const ac = new AbortController()
36
+ const timer = setTimeout(() => ac.abort(), timeoutMs)
37
+ try {
38
+ const resp = await fetchImpl(`${origin}/health`, { signal: ac.signal })
39
+ return resp.status >= 200 && resp.status < 300
40
+ } catch {
41
+ return false
42
+ } finally {
43
+ clearTimeout(timer)
44
+ }
45
+ }
46
+
47
+ function resolveDaemonEntry(): string {
48
+ // Test escape hatch: when set, tests can inject a fake daemon entry without
49
+ // having to mock child_process.spawn inside a subprocess.
50
+ const override = process.env.CROSS_AGENT_TEAMS_CHANNEL_DAEMON_ENTRY
51
+ if (override && override.length > 0) return override
52
+ // After tsup bundles, this module is inlined into dist/channel-cli.js;
53
+ // the daemon entry sits next to it as dist/cli.js. Resolving relative to
54
+ // import.meta.url (which equals the channel-cli.js URL post-build) gives
55
+ // the right neighbor file.
56
+ return fileURLToPath(new URL('./cli.js', import.meta.url))
57
+ }
58
+
59
+ function ensureLogDir(logPath: string): void {
60
+ try {
61
+ mkdirSync(dirname(logPath), { recursive: true })
62
+ } catch {
63
+ // best-effort — spawn will fail clearly if path still unusable
64
+ }
65
+ }
66
+
67
+ export async function ensureDaemonHealthy(opts: EnsureDaemonHealthyOptions): Promise<void> {
68
+ const fetchImpl = opts.fetchImpl ?? fetch
69
+ const spawnImpl = opts.spawnImpl ?? spawn
70
+ const probeTimeoutMs = opts.probeTimeoutMs ?? 2000
71
+ const pollAttempts = opts.pollAttempts ?? 20
72
+ const pollIntervalMs = opts.pollIntervalMs ?? 250
73
+ const logFilePath = opts.logFilePath ?? DEFAULT_LOG_PATH
74
+
75
+ let parsed: URL
76
+ try {
77
+ parsed = new URL(opts.daemonUrl)
78
+ } catch {
79
+ throw new Error(`invalid daemon URL: ${opts.daemonUrl}`)
80
+ }
81
+
82
+ const origin = originOf(parsed)
83
+ const host = parsed.hostname
84
+ const port = parsed.port ? Number(parsed.port) : null
85
+
86
+ // Step 1: probe.
87
+ if (await probeHealth(origin, probeTimeoutMs, fetchImpl)) return
88
+
89
+ // Step 2: non-loopback → no spawn, error out.
90
+ if (!isLoopbackHost(host)) {
91
+ throw new Error(
92
+ `daemon at ${opts.daemonUrl} not reachable; auto-spawn disabled for non-loopback URLs`
93
+ )
94
+ }
95
+
96
+ // Step 3: spawn detached daemon.
97
+ const entry = opts.daemonEntryOverride ?? resolveDaemonEntry()
98
+ const args = ['daemon']
99
+ if (port !== null && Number.isFinite(port)) {
100
+ args.push('--port', String(port))
101
+ }
102
+
103
+ ensureLogDir(logFilePath)
104
+ let logFd: number | undefined
105
+ try {
106
+ logFd = openSync(logFilePath, 'a')
107
+ } catch {
108
+ logFd = undefined
109
+ }
110
+
111
+ const spawnOptions: SpawnOptions = {
112
+ detached: true,
113
+ stdio: logFd !== undefined
114
+ ? ['ignore', logFd, logFd]
115
+ : 'ignore'
116
+ }
117
+ const child = spawnImpl(process.execPath, [entry, ...args], spawnOptions)
118
+ try { child.unref() } catch { /* best-effort */ }
119
+
120
+ // Step 4: poll /health.
121
+ for (let i = 0; i < pollAttempts; i++) {
122
+ await new Promise(r => setTimeout(r, pollIntervalMs))
123
+ if (await probeHealth(origin, probeTimeoutMs, fetchImpl)) return
124
+ }
125
+
126
+ throw new Error(
127
+ `daemon failed to become healthy at ${opts.daemonUrl} within bootstrap deadline; ` +
128
+ `see log at ${logFilePath}`
129
+ )
130
+ }
@@ -0,0 +1,155 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
2
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
3
+
4
+ export interface RegistrationConfig {
5
+ daemonUrl: string
6
+ channel_session_id: string
7
+ backoffInitialMs?: number
8
+ backoffMaxMs?: number
9
+ notificationHandler?: (payload: unknown) => void
10
+ }
11
+
12
+ export interface ReconnectingProxyConfig extends RegistrationConfig {
13
+ onSequenceComplete?: (order: string[]) => void
14
+ onDisconnect?: () => void
15
+ healthCheckIntervalMs?: number
16
+ }
17
+
18
+ export interface ReconnectingProxyController {
19
+ stop(): Promise<void>
20
+ }
21
+
22
+ export interface RegistrationSequenceResult {
23
+ order: string[]
24
+ lastSubscribeResult: unknown
25
+ client: Client
26
+ transport: StreamableHTTPClientTransport
27
+ close: () => Promise<void>
28
+ }
29
+
30
+ type ToolResult = Record<string, unknown>
31
+
32
+ async function parseToolResult(resp: unknown): Promise<ToolResult> {
33
+ const r = resp as { content?: Array<{ text?: string }> }
34
+ const text = r.content?.[0]?.text
35
+ if (typeof text !== 'string') return {}
36
+ try { return JSON.parse(text) as ToolResult } catch { return {} }
37
+ }
38
+
39
+ export async function runRegistrationSequence(
40
+ config: RegistrationConfig
41
+ ): Promise<RegistrationSequenceResult> {
42
+ const order: string[] = []
43
+ const transport = new StreamableHTTPClientTransport(new URL(config.daemonUrl))
44
+ const client = new Client({ name: 'cross-agent-teams-channel', version: '0.1.0' })
45
+
46
+ if (config.notificationHandler) {
47
+ client.fallbackNotificationHandler = async (n) => {
48
+ if (n.method === 'notifications/channel_wake') {
49
+ config.notificationHandler!(n.params)
50
+ }
51
+ }
52
+ }
53
+
54
+ await client.connect(transport)
55
+
56
+ // 1. register_agent as proxy — identity keyed on pid, stable across reconnects
57
+ // so the (team, name) ON CONFLICT upsert reuses the same row instead of spamming new rows
58
+ const registerResp = await client.callTool({
59
+ name: 'register_agent',
60
+ arguments: {
61
+ client: 'custom',
62
+ client_name: 'cross-agent-teams-channel',
63
+ model: 'proxy',
64
+ role: '__channel_proxy__',
65
+ name: `channel-proxy-${process.pid}`,
66
+ team: 'default',
67
+ claude_ui_pid: process.ppid,
68
+ delivery: {
69
+ kind: 'claude-channel',
70
+ channel_session_id: config.channel_session_id,
71
+ },
72
+ }
73
+ })
74
+ order.push('register_agent')
75
+ const regResult = await parseToolResult(registerResp)
76
+ if (!('agent_id' in regResult)) {
77
+ throw new Error(`register_agent failed: ${JSON.stringify(regResult)}`)
78
+ }
79
+
80
+ // 2. subscribe_channel_wake — proxy's csid is fresh per startup
81
+ const subResp = await client.callTool({
82
+ name: 'subscribe_channel_wake',
83
+ arguments: { channel_session_id: config.channel_session_id }
84
+ })
85
+ order.push('subscribe_channel_wake')
86
+ const subResult = await parseToolResult(subResp)
87
+ if (!('ok' in subResult) || subResult.ok !== true) {
88
+ throw new Error(`subscribe_channel_wake failed: ${JSON.stringify(subResult)}`)
89
+ }
90
+
91
+ return {
92
+ order,
93
+ lastSubscribeResult: subResult,
94
+ client,
95
+ transport,
96
+ close: async () => {
97
+ try { await client.close() } catch { /* best-effort */ }
98
+ try { await transport.close() } catch { /* best-effort */ }
99
+ }
100
+ }
101
+ }
102
+
103
+ export function runReconnectingProxy(config: ReconnectingProxyConfig): ReconnectingProxyController {
104
+ let stopped = false
105
+ let currentSeq: RegistrationSequenceResult | null = null
106
+
107
+ async function waitForDisconnect(seq: RegistrationSequenceResult): Promise<void> {
108
+ const interval = config.healthCheckIntervalMs ?? 200
109
+ let disconnected = false
110
+ const closeHandler = () => { disconnected = true }
111
+ const prevOnClose = seq.transport.onclose
112
+ seq.transport.onclose = () => { prevOnClose?.(); closeHandler() }
113
+ while (!disconnected && !stopped) {
114
+ await new Promise(r => setTimeout(r, interval))
115
+ if (disconnected || stopped) break
116
+ try {
117
+ await seq.client.callTool({ name: 'echo', arguments: { msg: 'hb' } })
118
+ } catch {
119
+ disconnected = true
120
+ break
121
+ }
122
+ }
123
+ }
124
+
125
+ async function loop(): Promise<void> {
126
+ while (!stopped) {
127
+ try {
128
+ const seq = await runRegistrationSequence(config)
129
+ currentSeq = seq
130
+ if (config.onSequenceComplete) config.onSequenceComplete([...seq.order])
131
+
132
+ await waitForDisconnect(seq)
133
+ if (config.onDisconnect) config.onDisconnect()
134
+ try { await seq.close() } catch { /* best-effort */ }
135
+ currentSeq = null
136
+ } catch {
137
+ // register/subscribe failed — wait and retry.
138
+ }
139
+ if (stopped) break
140
+ const wait = config.backoffInitialMs ?? 500
141
+ await new Promise(r => setTimeout(r, wait))
142
+ }
143
+ }
144
+
145
+ void loop()
146
+
147
+ return {
148
+ stop: async () => {
149
+ stopped = true
150
+ if (currentSeq) {
151
+ try { await currentSeq.close() } catch { /* best-effort */ }
152
+ }
153
+ }
154
+ }
155
+ }
@@ -0,0 +1,28 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2
+
3
+ export function createProxyServer(): McpServer {
4
+ return new McpServer(
5
+ { name: 'cross-agent-teams-channel', version: '0.1.0' },
6
+ { capabilities: { experimental: { 'claude/channel': {} } } }
7
+ )
8
+ }
9
+
10
+ export interface ChannelWakeParams {
11
+ content: string
12
+ meta: Record<string, string>
13
+ }
14
+
15
+ export function relayChannelWake(server: McpServer, params: ChannelWakeParams): void {
16
+ try {
17
+ const notif = {
18
+ method: 'notifications/claude/channel',
19
+ params: params as unknown as Record<string, unknown>
20
+ }
21
+ const p = (server.server.notification as (n: typeof notif) => Promise<void>)(notif)
22
+ if (p && typeof p.catch === 'function') {
23
+ p.catch(() => { /* host closed — drop silently */ })
24
+ }
25
+ } catch {
26
+ // host transport closed or not yet connected — drop silently
27
+ }
28
+ }
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ import { randomUUID } from 'node:crypto'
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
4
+ import { createProxyServer, relayChannelWake } from './channel/proxy.js'
5
+ import { runReconnectingProxy } from './channel/daemon-client.js'
6
+ import { ensureDaemonHealthy } from './channel/auto-daemon.js'
7
+
8
+ const DEFAULT_DAEMON_URL = 'http://127.0.0.1:9100/mcp'
9
+
10
+ interface CliArgs {
11
+ daemonUrl: string
12
+ }
13
+
14
+ export class CliArgError extends Error {
15
+ constructor(message: string) {
16
+ super(message)
17
+ this.name = 'CliArgError'
18
+ }
19
+ }
20
+
21
+ export function buildStartupHint(csid: string): { content: string; meta: { source: string; kind: string } } {
22
+ const content = [
23
+ `cross-agent-teams-mcp: your channel_session_id is ${csid}.`,
24
+ `Preferred in Claude Code: call register_claude_self({name: "<agent-name>", ui_pid: $PPID}) from this session — do NOT pass channel_session_id here; the daemon auto-binds via ui_pid.`,
25
+ `Unified equivalent: register_agent({client: "claude-code", name: "<agent-name>", model: "<model>", ui_pid: $PPID}) — also without channel_session_id.`,
26
+ `bind_channel({channel_session_id: "${csid}"}) is the low-level rebind tool for an already-registered Claude host that needs to switch to a fresh csid; it is NOT the primary registration path.`,
27
+ `Do not use curl or another external HTTP client for Claude registration here — that would create a different MCP session, and follow-up tools in Claude Code could still see unknown_agent.`
28
+ ].join(' ')
29
+ return {
30
+ content,
31
+ meta: { source: 'cross_agent_teams_mcp', kind: 'startup_bind_hint' }
32
+ }
33
+ }
34
+
35
+ export function parseCliArgs(argv: readonly string[], env: NodeJS.ProcessEnv = process.env): CliArgs {
36
+ let daemonUrl: string | undefined
37
+
38
+ for (let i = 0; i < argv.length; i++) {
39
+ const flag = argv[i]
40
+ const next = argv[i + 1]
41
+ switch (flag) {
42
+ case '--daemon-url':
43
+ daemonUrl = next; i++; break
44
+ default:
45
+ // Ignore unknown flags for forward-compat (including legacy
46
+ // --agent-team / --agent-name, which are no longer honored).
47
+ break
48
+ }
49
+ }
50
+
51
+ if (!daemonUrl || daemonUrl.length === 0) {
52
+ daemonUrl = env.CROSS_AGENT_TEAMS_MCP_DAEMON_URL
53
+ }
54
+
55
+ if (!daemonUrl || daemonUrl.length === 0) {
56
+ daemonUrl = DEFAULT_DAEMON_URL
57
+ }
58
+ return { daemonUrl }
59
+ }
60
+
61
+ export async function main(
62
+ argv: readonly string[] = process.argv.slice(2),
63
+ env: NodeJS.ProcessEnv = process.env
64
+ ): Promise<void> {
65
+ let args: CliArgs
66
+ try {
67
+ args = parseCliArgs(argv, env)
68
+ } catch (err) {
69
+ const msg = err instanceof Error ? err.message : String(err)
70
+ process.stderr.write(`cross-agent-teams-channel: ${msg}\n`)
71
+ process.exit(2)
72
+ }
73
+
74
+ // Fresh csid per startup — no persistence. Multi-instance safe.
75
+ const csid = randomUUID()
76
+
77
+ try {
78
+ await ensureDaemonHealthy({ daemonUrl: args.daemonUrl, log: process.stderr })
79
+ } catch (err) {
80
+ const msg = err instanceof Error ? err.message : String(err)
81
+ process.stderr.write(`cross-agent-teams-channel: ${msg}\n`)
82
+ process.exit(2)
83
+ }
84
+
85
+ const hostServer = createProxyServer()
86
+ const stdioTransport = new StdioServerTransport()
87
+
88
+ const controller = runReconnectingProxy({
89
+ daemonUrl: args.daemonUrl,
90
+ channel_session_id: csid,
91
+ notificationHandler: (params) => {
92
+ relayChannelWake(hostServer, params as { content: string; meta: Record<string, string> })
93
+ },
94
+ onSequenceComplete: () => {
95
+ // Announce csid to Claude via host-facing channel notification so Claude
96
+ // can call bind_channel({channel_session_id}) to bind its own agent row.
97
+ const hint = buildStartupHint(csid)
98
+ relayChannelWake(hostServer, hint)
99
+ }
100
+ })
101
+
102
+ let stopped = false
103
+ const shutdown = async (): Promise<void> => {
104
+ if (stopped) return
105
+ stopped = true
106
+ try { await controller.stop() } catch { /* best-effort */ }
107
+ try { await hostServer.close() } catch { /* best-effort */ }
108
+ process.exit(0)
109
+ }
110
+
111
+ stdioTransport.onclose = () => { void shutdown() }
112
+
113
+ await hostServer.connect(stdioTransport)
114
+
115
+ process.on('SIGTERM', () => { void shutdown() })
116
+ process.on('SIGINT', () => { void shutdown() })
117
+ }
118
+
119
+ // eslint-disable-next-line @typescript-eslint/no-misused-promises
120
+ if (import.meta.url === `file://${process.argv[1]}`) {
121
+ void main()
122
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { startServer } from './daemon/server.js'
6
+ import { wireShutdown } from './daemon/shutdown.js'
7
+ import { acquirePidFile } from './daemon/pid.js'
8
+ import { selectPort } from './daemon/port.js'
9
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
10
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
11
+
12
+ function parseArg(name: string, def?: string): string | undefined {
13
+ const i = process.argv.indexOf(name)
14
+ return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : def
15
+ }
16
+
17
+ function defaultHome(): string {
18
+ return process.env.CROSS_AGENT_TEAMS_MCP_HOME ?? join(homedir(), '.cross-agent-teams-mcp')
19
+ }
20
+
21
+ async function runDaemon(): Promise<void> {
22
+ const home = defaultHome()
23
+ const pidPath = parseArg('--pid-file', join(home, 'daemon.pid'))!
24
+ const dbPath = parseArg('--db', join(home, 'data.db'))!
25
+ const token = parseArg('--token')
26
+ const requested = Number(parseArg('--port', '9100'))
27
+ const port = requested === 0 ? 0 : await selectPort([requested, requested + 1, requested + 2])
28
+ const r = acquirePidFile(pidPath, port || requested)
29
+ if (!r.ok) { console.error('daemon already running pid=' + r.pid); process.exit(1) }
30
+ const started = await startServer({ dbPath, token, port })
31
+ wireShutdown(started.app, pidPath)
32
+ console.log(`listening on ${started.host}:${started.port}`)
33
+ }
34
+
35
+ function resolveDaemonPort(explicit: string | undefined): number | undefined {
36
+ if (explicit !== undefined) {
37
+ const n = Number(explicit)
38
+ if (Number.isInteger(n) && n > 0) return n
39
+ return undefined
40
+ }
41
+ const pidPath = parseArg('--pid-file', join(defaultHome(), 'daemon.pid'))!
42
+ if (!existsSync(pidPath)) return undefined
43
+ try {
44
+ const parsed = JSON.parse(readFileSync(pidPath, 'utf8')) as { port?: number }
45
+ if (typeof parsed.port === 'number' && parsed.port > 0) return parsed.port
46
+ } catch { /* ignore corrupt pid file */ }
47
+ return undefined
48
+ }
49
+
50
+ async function runPreRegisterCodexPane(): Promise<void> {
51
+ const pane = parseArg('--pane')
52
+ const agentId = parseArg('--agent-id')
53
+ const ttlRaw = parseArg('--ttl')
54
+ const tokenExplicit = parseArg('--token')
55
+ const portExplicit = parseArg('--port')
56
+
57
+ if (!pane || !agentId) {
58
+ console.error('usage: cross-agent-teams-mcp pre-register-codex-pane --pane <pane_id> --agent-id <uuid> [--ttl <seconds>] [--port <n>] [--token <t>]')
59
+ process.exit(2)
60
+ }
61
+
62
+ const port = resolveDaemonPort(portExplicit)
63
+ if (!port) {
64
+ console.error('{"ok":false,"error":"daemon_port_unresolved","detail":"pass --port or start the daemon so the pid file is present"}')
65
+ process.exit(1)
66
+ }
67
+
68
+ const token = tokenExplicit ?? process.env.CROSS_AGENT_TEAMS_MCP_TOKEN
69
+ const host = process.env.CROSS_AGENT_TEAMS_MCP_HOST ?? '127.0.0.1'
70
+ const base = new URL(`http://${host}:${port}/mcp`)
71
+
72
+ const requestInit: RequestInit | undefined = token
73
+ ? { headers: { Authorization: `Bearer ${token}` } }
74
+ : undefined
75
+
76
+ const transport = new StreamableHTTPClientTransport(base, {
77
+ requestInit,
78
+ })
79
+ const client = new Client({ name: 'cross-agent-teams-mcp-cli', version: '0.1.0' })
80
+
81
+ try {
82
+ await client.connect(transport)
83
+ const args: Record<string, unknown> = {
84
+ pane_id: pane,
85
+ xats_agent_id: agentId,
86
+ }
87
+ if (ttlRaw !== undefined) {
88
+ const ttl = Number(ttlRaw)
89
+ if (!Number.isInteger(ttl) || ttl <= 0) {
90
+ console.error('{"ok":false,"error":"invalid_ttl"}')
91
+ process.exit(2)
92
+ }
93
+ args.ttl_seconds = ttl
94
+ }
95
+ const resp = await client.callTool({
96
+ name: 'pre_register_codex_pane',
97
+ arguments: args,
98
+ })
99
+ const content = (resp as { content?: Array<{ text?: string }> }).content
100
+ const text = content?.[0]?.text ?? ''
101
+ let parsed: unknown
102
+ try { parsed = JSON.parse(text) } catch { parsed = { raw: text } }
103
+ const obj = (parsed ?? {}) as Record<string, unknown>
104
+ if (obj.ok === true) {
105
+ console.log(JSON.stringify(obj))
106
+ process.exit(0)
107
+ }
108
+ console.error(JSON.stringify(obj))
109
+ process.exit(1)
110
+ } catch (error) {
111
+ const msg = error instanceof Error ? error.message : String(error)
112
+ console.error(JSON.stringify({ ok: false, error: 'cli_failed', detail: msg }))
113
+ process.exit(1)
114
+ } finally {
115
+ try { await transport.close() } catch { /* best-effort */ }
116
+ try { await client.close() } catch { /* best-effort */ }
117
+ }
118
+ }
119
+
120
+ async function main(): Promise<void> {
121
+ const cmd = process.argv[2]
122
+ if (cmd === 'daemon') {
123
+ await runDaemon()
124
+ return
125
+ }
126
+ if (cmd === 'pre-register-codex-pane') {
127
+ await runPreRegisterCodexPane()
128
+ return
129
+ }
130
+ console.error('usage: cross-agent-teams-mcp <daemon|pre-register-codex-pane> [options]')
131
+ process.exit(2)
132
+ }
133
+
134
+ main().catch((e) => { console.error(e?.message ?? e); process.exit(1) })
135
+
136
+ export {}
@@ -0,0 +1,17 @@
1
+ import type { FastifyRequest, FastifyReply } from 'fastify'
2
+
3
+ export function extractToken(req: FastifyRequest): string | undefined {
4
+ const h = req.headers['authorization']
5
+ if (typeof h === 'string' && h.startsWith('Bearer ')) return h.slice(7)
6
+ const q = (req.query as Record<string, unknown> | undefined)?.token
7
+ return typeof q === 'string' ? q : undefined
8
+ }
9
+
10
+ export function makeAuthHook(expected: string | undefined) {
11
+ return async (req: FastifyRequest, reply: FastifyReply) => {
12
+ if (req.url.startsWith('/health')) return
13
+ if (!expected) return
14
+ const got = extractToken(req)
15
+ if (got !== expected) return reply.code(401).send({ error: 'invalid_token' })
16
+ }
17
+ }
@@ -0,0 +1,39 @@
1
+ export type ChannelWakeSink = (payload: unknown) => void
2
+
3
+ interface Entry {
4
+ sessionId: string
5
+ sink: ChannelWakeSink
6
+ }
7
+
8
+ export class ChannelWakeFanout {
9
+ private readonly entries = new Map<string, Entry>()
10
+
11
+ attach(channel_session_id: string, sink: ChannelWakeSink, sessionId: string): void {
12
+ this.entries.set(channel_session_id, { sessionId, sink })
13
+ }
14
+
15
+ detach(channel_session_id: string): void {
16
+ this.entries.delete(channel_session_id)
17
+ }
18
+
19
+ detachBySession(sessionId: string): void {
20
+ for (const [csid, entry] of this.entries) {
21
+ if (entry.sessionId === sessionId) this.entries.delete(csid)
22
+ }
23
+ }
24
+
25
+ send(channel_session_id: string, payload: unknown): boolean {
26
+ const entry = this.entries.get(channel_session_id)
27
+ if (!entry) return false
28
+ try {
29
+ entry.sink(payload)
30
+ } catch {
31
+ // sink failure is the caller's concern; swallow to preserve map state
32
+ }
33
+ return true
34
+ }
35
+
36
+ has(channel_session_id: string): boolean {
37
+ return this.entries.has(channel_session_id)
38
+ }
39
+ }